MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
# DEST_DIR will have the build package copied.
-DEST_DIR=/var/www/arvados-workbench2/workbench2/
+DEST_DIR=/var/www/$(APP_NAME)/workbench2/
# Debian package file
DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
[comment]: # ()
[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
-## Arvados Workbench 2
+# Arvados Workbench 2
-### Setup
-<pre>
-brew install yarn
+## Setup
+```
+npm install yarn
yarn install
-</pre>
+```
+
Install [redux-devtools-extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
-### Start project
-<code>yarn start</code>
+## Start project for development
+```
+yarn start
+```
-### Run unit tests
-<pre>
+## Run unit tests
+```
make unit-tests
-</pre>
+```
-### Run end-to-end tests
+## Run end-to-end tests
-<pre>
+```
make integration-tests
-</pre>
+```
-### Run end-to-end tests in a Docker container
+## Run end-to-end tests in a Docker container
-<pre>
+```
make integration-tests-in-docker
-</pre>
+```
-### Run tests interactively in container
+## Run tests interactively in container
-<pre>
+```
$ xhost +local:root
$ ARVADOS_DIR=/path/to/arvados
$ docker run -ti -v$PWD:$PWD -v$ARVADOS_DIR:/usr/src/arvados -w$PWD --env="DISPLAY" --volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" workbench2-build /bin/bash
(inside container)
# yarn run cypress install
# tools/run-integration-tests.sh -i -a /usr/src/arvados
-</pre>
+```
-### Production build
-<pre>
-yarn install
+## Production build
+```
yarn build
-</pre>
+```
-### Package build
-<pre>
-make workbench2-build-image
-docker run -v$PWD:$PWD -w $PWD arvados/fpm make packages
-</pre>
+## Package build
+```
+make packages
+```
-### Build time configuration
+## Build time configuration
You can customize project global variables using env variables. Default values are placed in the `.env` file.
Example:
REACT_APP_ARVADOS_CONFIG_URL=config.json yarn build
```
-### Run time configuration
-The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`. You can customize this url using build time configuration.
+## Run time configuration
+The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`. In development mode, this can be found in the `public` directory.
+You can customize this url using build time configuration.
Currently this configuration schema is supported:
```
}
```
-#### API_HOST
+### API_HOST
The Arvados base URL.
The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable.
-#### VOCABULARY_URL
+### VOCABULARY_URL
Local path, or any URL that allows cross-origin requests. See
[Vocabulary JSON file example](public/vocabulary-example.json).
To use the URL defined in the Arvados cluster configuration, remove the entire `VOCABULARY_URL` entry from the runtime configuration. Found in `/config.json` by default.
-### FILE_VIEWERS_CONFIG_URL
+## FILE_VIEWERS_CONFIG_URL
Local path, or any URL that allows cross-origin requests. See:
[File viewers config file example](public/file-viewers-example.json)
To use the URL defined in the Arvados cluster configuration, remove the entire `FILE_VIEWERS_CONFIG_URL` entry from the runtime configuration. Found in `/config.json` by default.
-### Licensing
+## Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface. For information about installing plugins, the provided
+example plugins, see [src/plugins/README.md](src/plugins/README.md).
+
+
+## Licensing
Arvados is Free Software. See COPYING for information about Arvados Free
Software licenses.
//
// 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.
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 = '';
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
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');
.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, {
.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`);
+ })
+ })
+
+})
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";
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Dispatch, Middleware } from 'redux';
+import { RootStore, RootState } from '~/store/store';
+import { ResourcesState } from '~/store/resources/resources';
+import { Location } from 'history';
+import { ServiceRepository } from "~/services/services";
+
+export type ElementListReducer = (startingList: React.ReactElement[], itemClass?: string) => React.ReactElement[];
+export type CategoriesListReducer = (startingList: string[]) => string[];
+export type NavigateMatcher = (dispatch: Dispatch, getState: () => RootState, uuid: string) => boolean;
+export type LocationChangeMatcher = (store: RootStore, pathname: string) => boolean;
+export type EnableNew = (location: Location, currentItemId: string, currentUserUUID: string | undefined, resources: ResourcesState) => boolean;
+export type MiddlewareListReducer = (startingList: Middleware[], services: ServiceRepository) => Middleware[];
+
+/* Workbench Plugin API
+
+ Code to your plugin should go into a subdirectory of '~/plugins'.
+
+ Your plugin should implement a "register" function, which will be
+ called with an object with the PluginConfig interface described
+ below. The register function may make in-place modifications to
+ the pluginConfig object, but to preserve composability, it is
+ strongly advised this should be limited to push()ing new values
+ onto the various lists of hooks.
+
+ To enable a plugin, edit 'plugins.tsx', import the register
+ function exported by the plugin, and add a call to the register
+ function following the examples in the comments. Then, build a new
+ Workbench package that includes the plugin.
+
+ Be aware that because plugins heavily leverage workbench, and in
+ fact must be compiled together, they are considered "derived works"
+ and so _must_ be license-compatible with AGPL-3.0.
+
+ */
+
+export interface PluginConfig {
+
+ /* During initialization, each
+ * function in the callback list will be called with the list of
+ * react - router "Route" components that will be used select what should
+ * be displayed in the central panel based on the navigation bar.
+ *
+ * The callback function may add, edit, or remove items from this list,
+ * and return a new list of components, which will be passed to the next
+ * function in `centerPanelList`.
+ *
+ * The hooks are applied in `views/workbench/workbench.tsx`.
+ * */
+ centerPanelList: ElementListReducer[];
+
+ /* During initialization, each
+ * function in the callback list will be called with the list of strings
+ * that are the top-level categories in the left hand navigation tree.
+ *
+ * The callback function may add, edit, or remove items from this list,
+ * and return a new list of strings, which will be passed to the next
+ * function in `sidePanelCategories`.
+ *
+ * The hooks are applied in `store/side-panel-tree/side-panel-tree-actions.ts`.
+ * */
+ sidePanelCategories: CategoriesListReducer[];
+
+ /* This is a list of additional dialog box components.
+ * Dialogs are components that are wrapped using the "withDialog()" method.
+ *
+ * These are added to the list in `views/workbench/workbench.tsx`.
+ * */
+ dialogs: React.ReactElement[];
+
+ /* This is a list of additional navigation matchers.
+ * These are callbacks that are called by the navigateTo(uuid) method to
+ * set the path in the navigation bar to display the desired resource.
+ * Each handler should return "true" if the uuid was handled and "false or "undefined" if not.
+ *
+ * These are used in `store/navigation/navigation-action.tsx`.
+ * */
+ navigateToHandlers: NavigateMatcher[];
+
+ /* This is a list of additional location change matchers.
+ * These are callbacks called when the URL in the navigation bar changes
+ * (this could be in response to "navigateTo()" or due to the user
+ * entering/changing the URL directly).
+ *
+ * The Route components in centerPanelList should
+ * automatically change in response to navigation. The
+ * purpose of these handlers is trigger additional loading,
+ * such as fetching the object contents that will be
+ * displayed.
+ *
+ * Each handler should return "true" if the path was handled and "false or "undefined" if not.
+ *
+ * These are used in `routes/route-change-handlers.ts`.
+ */
+ locationChangeHandlers: LocationChangeMatcher[];
+
+ /* Replace the left side of the app bar. Normally, this displays
+ * the site banner.
+ *
+ * Note: unlike most of the other hooks, this is not composable.
+ * This completely replaces that section of the app bar. Multiple
+ * plugins setting this value will conflict.
+ *
+ * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+ */
+ appBarLeft?: React.ReactElement;
+
+ /* Replace the middle part of the app bar. Normally, this displays
+ * the search bar.
+ *
+ * Note: unlike most of the other hooks, this is not composable.
+ * This completely replaces that section of the app bar. Multiple
+ * plugins setting this value will conflict.
+ *
+ * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+ */
+ appBarMiddle?: React.ReactElement;
+
+ /* Replace the right part of the app bar. Normally, this displays
+ * the admin menu and help menu.
+ * (Note: the user menu can be customized separately using accountMenuList)
+ *
+ * Note: unlike most of the other hooks, this is not composable.
+ * This completely replaces that section of the app bar. Multiple
+ * plugins setting this value will conflict.
+ *
+ * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+ */
+ appBarRight?: React.ReactElement;
+
+ /* During initialization, each
+ * function in the callback list will be called with the menu items that
+ * will appear in the "user account" menu.
+ *
+ * The callback function may add, edit, or remove items from this list,
+ * and return a new list of menu items, which will be passed to the next
+ * function in `accountMenuList`.
+ *
+ * The hooks are applied in 'views-components/main-app-bar/account-menu.tsx'.
+ * */
+ accountMenuList: ElementListReducer[];
+
+ /* Each function in this list is called to determine if the the "NEW" button
+ * should be enabled or disabled. If any function returns "true", the button
+ * (and corresponding drop-down menu) will be enabled.
+ *
+ * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+ * */
+ enableNewButtonMatchers: EnableNew[];
+
+ /* During initialization, each
+ * function in the callback list will be called with the menu items that
+ * will appear in the "NEW" dropdown menu.
+ *
+ * The callback function may add, edit, or remove items from this list,
+ * and return a new list of menu items, which will be passed to the next
+ * function in `newButtonMenuList`.
+ *
+ * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+ * */
+ newButtonMenuList: ElementListReducer[];
+
+ /* Add Middlewares to the Redux store.
+ *
+ * Middlewares intercept redux actions before they get to the reducer, and
+ * may produce side effects. For example, the REQUEST_ITEMS action is intercepted by a middleware to
+ * trigger a load of data table contents.
+ *
+ * https://redux.js.org/tutorials/fundamentals/part-4-store#middleware
+ *
+ * Used in 'store/store.ts'
+ * */
+ middlewares: MiddlewareListReducer[];
+}
--- /dev/null
+// 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
+// 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 + '=([^&#]*)');
}
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;
+};
//
// SPDX-License-Identifier: AGPL-3.0
+import { customEncodeURI } from "./url";
import { WebDAV } from "./webdav";
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);
});
//
// SPDX-License-Identifier: AGPL-3.0
+import { customEncodeURI } from "./url";
+
export class WebDAV {
defaults: WebDAVDefaults = {
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)
//
// 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) => {
dialogTitle: string;
formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
submitLabel?: string;
+ enableWhenPristine?: boolean;
}
type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>;
onClick={props.handleSubmit}
className={props.classes.lastButton}
color="primary"
- disabled={props.invalid || props.submitting || props.pristine}
+ disabled={props.invalid || props.submitting || (props.pristine && !props.enableWhenPristine)}
variant="contained">
{props.submitLabel || 'Submit'}
{props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
</form>
</Dialog>
);
-
-
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';
export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
export const ProcessIcon: IconType = (props) => <BubbleChart {...props} />;
export const ProjectIcon: IconType = (props) => <Folder {...props} />;
+export const FilterGroupIcon: IconType = (props) => <Pageview {...props} />;
export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
export const RemoveIcon: IconType = (props) => <Delete {...props} />;
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";
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'
toggleActive: 'TOGGLE_ACTIVE',
};
-const ItemIcon = React.memo(({type, kind, active, classes}: any) => {
+const ItemIcon = React.memo(({type, kind, active, groupClass, classes}: any) => {
let Icon = ProjectIcon;
- if (type) {
- switch (type) {
- case 'directory':
- Icon = DirectoryIcon;
- break;
- case 'file':
- Icon = FileIcon;
- break;
- default:
- Icon = DefaultIcon;
- }
+ if (groupClass === GroupClass.FILTER) {
+ Icon = FilterGroupIcon;
+ }
+
+ if (type) {
+ switch (type) {
+ case 'directory':
+ Icon = DirectoryIcon;
+ break;
+ case 'file':
+ Icon = FileIcon;
+ break;
+ default:
+ Icon = DefaultIcon;
}
+ }
- if (kind) {
- switch(kind) {
- case ResourceKind.COLLECTION:
- Icon = CollectionIcon;
- break;
- default:
- break;
- }
+ if (kind) {
+ switch(kind) {
+ case ResourceKind.COLLECTION:
+ Icon = CollectionIcon;
+ break;
+ default:
+ break;
}
+ }
return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
});
</i>
<div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer}>
<span style={{ display: 'flex', alignItems: 'center' }}>
- <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} classes={props.classes} />
+ <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
<span style={{ fontSize: '0.875rem' }}>
{item.data.name}
</span>
class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
render(): ReactElement<any> {
const { items, render } = this.props;
-
return <AutoSizer>
{({ height, width }) => {
return VirtualList(height, width, items || [], render, this.props);
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';
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';
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';
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);
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);
addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
storeRedirects();
? error.errors[0]
: error.message}`,
kind: SnackbarKind.ERROR,
- hideDuration: 8000})
+ hideDuration: 8000
+ })
);
}
}
}
export enum GroupClass {
- PROJECT = "project"
+ PROJECT = 'project',
+ FILTER = 'filter',
}
//
// SPDX-License-Identifier: AGPL-3.0
-import { TagProperty } from "~/models/tag";
import { Resource, ResourceKind } from '~/models/resource';
export interface LinkResource extends Resource {
tailKind: string;
linkClass: string;
name: string;
- properties: TagProperty;
+ properties: any;
kind: ResourceKind.LINK;
}
TAG = 'tag',
PERMISSION = 'permission',
PRESET = 'preset',
-}
\ No newline at end of file
+}
import { GroupClass, GroupResource } from "./group";
export interface ProjectResource extends GroupResource {
- groupClass: GroupClass.PROJECT;
+ groupClass: GroupClass.PROJECT | GroupClass.FILTER;
}
export const getProjectUrl = (uuid: string) => {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from '~/common/plugintypes';
+
+export const pluginConfig: PluginConfig = {
+ centerPanelList: [],
+ sidePanelCategories: [],
+ dialogs: [],
+ navigateToHandlers: [],
+ locationChangeHandlers: [],
+ appBarLeft: undefined,
+ appBarMiddle: undefined,
+ appBarRight: undefined,
+ accountMenuList: [],
+ enableNewButtonMatchers: [],
+ newButtonMenuList: [],
+ middlewares: []
+};
+
+// Starting here, import and register your Workbench 2 plugins. //
+
+// import { register as blankUIPluginRegister } from '~/plugins/blank/index';
+// import { register as examplePluginRegister } from '~/plugins/example/index';
+// import { register as rootRedirectRegister } from '~/plugins/root-redirect/index';
+
+// blankUIPluginRegister(pluginConfig);
+// examplePluginRegister(pluginConfig);
+// rootRedirectRegister(pluginConfig, exampleRoutePath);
--- /dev/null
+[comment]: # (Copyright © The Arvados Authors. All rights reserved.)
+[comment]: # ()
+[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
+
+# Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface. It is also possible to remove the majority of standard UI
+elements and replace them with your own, enabling you to use workbench
+as a basis for developing essentially new applications for Arvados.
+
+## Installing plugins
+
+1. Check out the source of your plugin into a directory under `arvados-workbench2/src/plugins`
+
+2. Register the plugin by editing `arvados-workbench2/src/plugins/plugins.tsx`.
+It will look something like this:
+
+```
+import { register as examplePluginRegister } from '~/plugins/example/index';
+examplePluginRegister(pluginConfig);
+```
+
+3. Rebuild Workbench 2
+
+For testing/development: `yarn start`
+
+For production: `APP_NAME=arvados-workbench2-with-custom-plugins make packages`
+
+Set `APP_NAME=` to whatever you like, but it is important to name it
+differently from the standard `arvados-workbench2` to avoid confusion.
+
+## Existing plugins
+
+### example
+
+This is an example plugin showing how to add a new navigation tree
+item, displaying a new center panel, as well as adding account menu
+and "New" menu items, and showing how to use SET_PROPERTY and
+getProperty() for state.
+
+### blank
+
+This deletes all of the existing user interface. If you want the
+application to only display your plugin's UI elements and none of the
+standard elements, you would load and register this first.
+
+### root-redirect
+
+This helper takes a path when registered. It tweaks the navigation
+behavior so that the default starting location when the application
+loads will be the path you provide, instead of "Projects".
+
+### sample-tracker
+
+This is a a new set of user interface screens that assist with
+clinical sample tracking and analysis. It is intended as a demo of
+how a real-world application can built using the Workbench 2
+plug-in interface. It can be found at
+https://github.com/arvados/sample-tracker .
+
+## Developing plugins
+
+For information about the plugin API, see
+[../common/plugintypes.ts](src/common/plugintypes.ts).
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example plugin.
+
+import { PluginConfig } from '~/common/plugintypes';
+import * as React from 'react';
+
+export const register = (pluginConfig: PluginConfig) => {
+
+ pluginConfig.centerPanelList.push((elms) => []);
+
+ pluginConfig.sidePanelCategories.push((cats: string[]): string[] => []);
+
+ pluginConfig.accountMenuList.push((elms) => []);
+ pluginConfig.newButtonMenuList.push((elms) => []);
+
+ pluginConfig.appBarLeft = <span />;
+ pluginConfig.appBarMiddle = <span />;
+ pluginConfig.appBarRight = <span />;
+};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { ServiceRepository } from "~/services/services";
+import { Dispatch } from "redux";
+import { RootState } from '~/store/store';
+import { initialize } from 'redux-form';
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { reduxForm, InjectedFormProps, Field, reset, startSubmit } from 'redux-form';
+import { TextField } from "~/components/text-field/text-field";
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { compose } from "redux";
+import { propertiesActions } from "~/store/properties/properties-actions";
+import { DispatchProp, connect } from 'react-redux';
+import { MenuItem } from "@material-ui/core";
+import { Card, CardContent, Typography } from "@material-ui/core";
+
+// This is the name of the dialog box. It in store actions that
+// open/close the dialog box.
+export const EXAMPLE_DIALOG_FORM_NAME = "exampleFormName";
+
+// This is the name of the property that will be used to store the
+// "pressed" count
+export const propertyKey = "Example_menu_item_pressed_count";
+
+// The model backing the form.
+export interface ExampleFormDialogData {
+ pressedCount: number | string; // Supposed to start as a number but TextField seems to turn this into a string, unfortunately.
+}
+
+// The actual component with the editing fields. Enables editing
+// the 'pressedCount' field.
+const ExampleEditFields = () => <span>
+ <Field
+ name='pressedCount'
+ component={TextField}
+ type="number"
+ />
+</span>;
+
+// Callback for when the form is submitted.
+const submitEditedPressedCount = (data: ExampleFormDialogData) =>
+ (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(startSubmit(EXAMPLE_DIALOG_FORM_NAME));
+ dispatch(propertiesActions.SET_PROPERTY({
+ key: propertyKey, value: parseInt(data.pressedCount as string, 10)
+ }));
+ dispatch(dialogActions.CLOSE_DIALOG({ id: EXAMPLE_DIALOG_FORM_NAME }));
+ dispatch(reset(EXAMPLE_DIALOG_FORM_NAME));
+ };
+
+// Props for the dialog component
+type DialogExampleProps = WithDialogProps<{ updating: boolean }> & InjectedFormProps<ExampleFormDialogData>;
+
+// This is the component that renders the dialog.
+const DialogExample = (props: DialogExampleProps) =>
+ <FormDialog
+ dialogTitle="Edit pressed count"
+ formFields={ExampleEditFields}
+ submitLabel="Update pressed count"
+ {...props}
+ />;
+
+// This ties it all together, withDialog() determines if the dialog is
+// visible based on state, and reduxForm manages the values of the
+// dialog's fields.
+export const ExampleDialog = compose(
+ withDialog(EXAMPLE_DIALOG_FORM_NAME),
+ reduxForm<ExampleFormDialogData>({
+ form: EXAMPLE_DIALOG_FORM_NAME,
+ onSubmit: (data, dispatch) => {
+ dispatch(submitEditedPressedCount(data));
+ }
+ })
+)(DialogExample);
+
+
+// Callback, dispatches an action to set the value of property
+// "Example_menu_item_pressed_count"
+const incrementPressedCount = (dispatch: Dispatch, pressedCount: number) => {
+ dispatch(propertiesActions.SET_PROPERTY({ key: propertyKey, value: pressedCount + 1 }));
+};
+
+// Callback, dispatches actions required to initialize and open the
+// dialog box.
+export const openExampleDialog = (pressedCount: number) =>
+ (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(initialize(EXAMPLE_DIALOG_FORM_NAME, { pressedCount }));
+ dispatch(dialogActions.OPEN_DIALOG({
+ id: EXAMPLE_DIALOG_FORM_NAME, data: {}
+ }));
+ };
+
+// Props definition used for menu items.
+interface ExampleProps {
+ pressedCount: number;
+ className?: string;
+}
+
+// Called to get the props from the redux state for several of the
+// following components.
+// Gets the value of the property "Example_menu_item_pressed_count"
+// from the state and puts it in 'pressedCount'
+const exampleMapStateToProps = (state: RootState) => ({ pressedCount: state.properties[propertyKey] || 0 });
+
+// Define component for the menu item that incremens the count each time it is pressed.
+export const ExampleMenuComponent = connect(exampleMapStateToProps)(
+ ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+ <MenuItem className={className} onClick={() => incrementPressedCount(dispatch, pressedCount)}>Example menu item</MenuItem >
+);
+
+// Define component for the menu item that opens the dialog box that lets you edit the count directly.
+export const ExampleDialogMenuComponent = connect(exampleMapStateToProps)(
+ ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+ <MenuItem className={className} onClick={() => dispatch(openExampleDialog(pressedCount))}>Open example dialog</MenuItem >
+);
+
+// The central panel. Displays the "pressed" count.
+export const ExamplePluginMainPanel = connect(exampleMapStateToProps)(
+ ({ pressedCount }: ExampleProps) =>
+ <Card>
+ <CardContent>
+ <Typography>
+ This is a example main panel plugin. The example menu item has been pressed {pressedCount} times.
+ </Typography>
+ </CardContent>
+ </Card>);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example workbench plugin. The entry point is the "register" method.
+
+import { PluginConfig } from '~/common/plugintypes';
+import * as React from 'react';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { push } from "react-router-redux";
+import { Route, matchPath } from "react-router";
+import { RootStore } from '~/store/store';
+import { activateSidePanelTreeItem } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { setSidePanelBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { Location } from 'history';
+import { handleFirstTimeLoad } from '~/store/workbench/workbench-actions';
+import {
+ ExampleDialog,
+ ExamplePluginMainPanel,
+ ExampleMenuComponent,
+ ExampleDialogMenuComponent
+} from './exampleComponents';
+
+const categoryName = "Plugin Example";
+export const routePath = "/examplePlugin";
+
+export const register = (pluginConfig: PluginConfig) => {
+
+ // Add this component to the main panel. When the app navigates
+ // to '/examplePlugin' it will render ExamplePluginMainPanel.
+ pluginConfig.centerPanelList.push((elms) => {
+ elms.push(<Route path={routePath} component={ExamplePluginMainPanel} />);
+ return elms;
+ });
+
+ // Add ExampleDialogMenuComponent to the upper-right user account menu
+ pluginConfig.accountMenuList.push((elms, menuItemClass) => {
+ elms.push(<ExampleDialogMenuComponent className={menuItemClass} />);
+ return elms;
+ });
+
+ // Add ExampleMenuComponent to the "New" button dropdown.
+ pluginConfig.newButtonMenuList.push((elms, menuItemClass) => {
+ elms.push(<ExampleMenuComponent className={menuItemClass} />);
+ return elms;
+ });
+
+ // Add a hook so that when the 'Plugin Example' entry in the left
+ // hand tree view is clicked, which calls navigateTo('Plugin Example'),
+ // it will be implemented by navigating to '/examplePlugin'
+ pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+ if (uuid === categoryName) {
+ dispatch(push(routePath));
+ return true;
+ }
+ return false;
+ });
+
+ // Adds 'Plugin Example' to the left hand tree view.
+ pluginConfig.sidePanelCategories.push((cats: string[]): string[] => { cats.push(categoryName); return cats; });
+
+ // When the location changes to '/examplePlugin', make sure
+ // 'Plugin Example' in the left hand tree view is selected, and
+ // make sure the breadcrumbs are updated.
+ pluginConfig.locationChangeHandlers.push((store: RootStore, pathname: string): boolean => {
+ if (matchPath(pathname, { path: routePath, exact: true })) {
+ store.dispatch(handleFirstTimeLoad(
+ (dispatch: Dispatch) => {
+ dispatch<any>(activateSidePanelTreeItem(categoryName));
+ dispatch<any>(setSidePanelBreadcrumbs(categoryName));
+ }));
+ return true;
+ }
+ return false;
+ });
+
+ // The "New" button can enabled or disabled based on the current
+ // context or selection. This adds a new callback to that will
+ // enable the "New" button when the location is '/examplePlugin'
+ pluginConfig.enableNewButtonMatchers.push((location: Location) => (!!matchPath(location.pathname, { path: routePath, exact: true })));
+
+ // Add the example dialog box to the list of dialog box controls.
+ pluginConfig.dialogs.push(<ExampleDialog />);
+};
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from '~/common/plugintypes';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { push } from "react-router-redux";
+
+export const register = (pluginConfig: PluginConfig, redirect: string) => {
+
+ pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+ if (uuid === SidePanelTreeCategory.PROJECTS) {
+ dispatch(push(redirect));
+ return true;
+ }
+ return false;
+ });
+};
import { dialogActions } from '~/store/dialog/dialog-actions';
import { contextMenuActions } from '~/store/context-menu/context-menu-actions';
import { searchBarActions } from '~/store/search-bar/search-bar-actions';
+import { pluginConfig } from '~/plugins';
export const addRouteChangeHandlers = (history: History, store: RootStore) => {
const handler = handleLocationChange(store);
store.dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
store.dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
+ for (const locChangeFn of pluginConfig.locationChangeHandlers) {
+ if (locChangeFn(store, pathname)) {
+ return;
+ }
+ }
+
if (projectMatch) {
store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
} else if (collectionMatch) {
} else if (allProcessesMatch) {
store.dispatch(WorkbenchActions.loadAllProcesses());
}
-};
\ No newline at end of file
+};
--- /dev/null
+// 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 = `
+ <?xml version="1.0" encoding="UTF-8"?>
+ <D:multistatus xmlns:D="DAV:">
+ <D:response>
+ <D:href>/c=xxxxx-zzzzz-vvvvvvvvvvvvvvv/</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:resourcetype>
+ <D:collection xmlns:D="DAV:"/>
+ </D:resourcetype>
+ <D:supportedlock>
+ <D:lockentry xmlns:D="DAV:">
+ <D:lockscope>
+ <D:exclusive/>
+ </D:lockscope>
+ <D:locktype>
+ <D:write/>
+ </D:locktype>
+ </D:lockentry>
+ </D:supportedlock>
+ <D:displayname></D:displayname>
+ <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ <D:response>
+ <D:href>/c=${collUUID}/${inputURL}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:resourcetype></D:resourcetype>
+ <D:getcontenttype>application/pdf</D:getcontenttype>
+ <D:supportedlock>
+ <D:lockentry xmlns:D="DAV:">
+ <D:lockscope>
+ <D:exclusive/>
+ </D:lockscope>
+ <D:locktype>
+ <D:write/>
+ </D:locktype>
+ </D:lockentry>
+ </D:supportedlock>
+ <D:displayname>${inputDisplayName}</D:displayname>
+ <D:getcontentlength>3</D:getcontentlength>
+ <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+ <D:getetag>"166feb9c9110c008325a59"</D:getetag>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `;
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(xmlData, "text/xml");
+
+ // when
+ const result = extractFilesData(xmlDoc);
+
+ // then
+ expect(result).toEqual([{ id: `${collUUID}/${expectedName}`, name: expectedName, path: "", size: 3, type: "file", url: `/c=${collUUID}/${expectedURL}` }]);
+ });
+ });
+ });
+
+ describe('getFileFullPath', () => {
+ it('should encode weird names', async () => {
+ // given
+ const file = {
+ name: '#test',
+ path: 'http://localhost',
+ } as CollectionFile;
+
+ // when
+ const result = getFileFullPath(file);
+
+ // then
+ expect(result).toBe('http://localhost/#test');
+ });
+
+ });
+});
\ No newline at end of file
.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
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;
async moveFile(collectionUuid: string, oldPath: string, newPath: string) {
await this.webdavClient.move(
`c=${collectionUuid}${oldPath}`,
- `c=${collectionUuid}${encodeURI(newPath)}`
+ `c=${collectionUuid}/${customEncodeURI(newPath)}`
);
await this.update(collectionUuid, { preserveVersion: true });
}
expect(axiosInstance.get).toHaveBeenCalledWith("/groups", {
params: {
filters: "[" + new FilterBuilder()
- .addEqual("group_class", "project")
+ .addIn("group_class", ["project", "filter"])
.getFilters() + "]",
order: undefined
}
filters: joinFilters(
args.filters || '',
new FilterBuilder()
- .addEqual("group_class", GroupClass.PROJECT)
+ .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
.getFilters()
)
});
export const setTrashBreadcrumbs = (uuid: string) =>
setCategoryBreadcrumbs(uuid, SidePanelTreeCategory.TRASH);
-export const setCategoryBreadcrumbs = (uuid: string, category: SidePanelTreeCategory) =>
+export const setCategoryBreadcrumbs = (uuid: string, category: string) =>
async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const ancestors = await services.ancestorsService.ancestors(uuid, '');
dispatch(updateResources(ancestors));
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', () => {
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,
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,
};
const store = mockStore(initialState);
- const menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string))
+ let menuKind: any;
try {
+ menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string, forceReadonly as boolean))
expect(menuKind).toBe(expected);
} catch (err) {
- throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} expected to be ${expected} but got ${menuKind}.`);
+ throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} forceReadonly: ${forceReadonly} expected to be ${expected} but got ${menuKind}.`);
}
});
});
});
-});
\ No newline at end of file
+});
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({
ownerUuid: string;
description?: string;
kind: ResourceKind,
- menuKind: ContextMenuKind;
+ menuKind: ContextMenuKind | string;
isTrashed?: boolean;
isEditable?: boolean;
outputUuid?: string;
kind: res.kind,
menuKind,
ownerUuid: res.ownerUuid,
- isTrashed: ('isTrashed' in res) ? res.isTrashed: false,
+ isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
}));
}
};
}
};
-export const resourceUuidToContextMenuKind = (uuid: string) =>
+export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
(dispatch: Dispatch, getState: () => RootState) => {
const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
const kind = extractUuidKind(uuid);
const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, userUuid)(getState().resources);
- const isEditable = isAdminUser || (resource || {} as EditableResource).isEditable;
+
+ const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly;
switch (kind) {
case ResourceKind.PROJECT:
- return !isAdminUser
- ? isEditable
- ? ContextMenuKind.PROJECT
- : ContextMenuKind.READONLY_PROJECT
- : ContextMenuKind.PROJECT_ADMIN;
+ return (isAdminUser && !readonly)
+ ? (resource && resource.groupClass !== GroupClass.FILTER)
+ ? ContextMenuKind.PROJECT_ADMIN
+ : ContextMenuKind.FILTER_GROUP_ADMIN
+ : isEditable
+ ? (resource && resource.groupClass !== GroupClass.FILTER)
+ ? ContextMenuKind.PROJECT
+ : ContextMenuKind.FILTER_GROUP
+ : ContextMenuKind.READONLY_PROJECT;
case ResourceKind.COLLECTION:
const c = getResource<CollectionResource>(uuid)(getState().resources);
if (c === undefined) { return; }
? 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:
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
import { RootState } from '~/store/store';
import { ServiceRepository } from '~/services/services';
import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { pluginConfig } from '~/plugins';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+
+const navigationNotAvailable = (id: string) =>
+ snackbarActions.OPEN_SNACKBAR({
+ message: `${id} not available`,
+ hideDuration: 3000,
+ kind: SnackbarKind.ERROR
+ });
export const navigateTo = (uuid: string) =>
async (dispatch: Dispatch, getState: () => RootState) => {
+
+ for (const navToFn of pluginConfig.navigateToHandlers) {
+ if (navToFn(dispatch, getState, uuid)) {
+ return;
+ }
+ }
+
const kind = extractUuidKind(uuid);
switch (kind) {
case ResourceKind.PROJECT:
}
switch (uuid) {
+ case SidePanelTreeCategory.PROJECTS:
+ const usr = getState().auth.user;
+ if (usr) {
+ dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
+ }
+ return;
case SidePanelTreeCategory.FAVORITES:
dispatch<any>(navigateToFavorites);
return;
dispatch(navigateToAllProcesses);
return;
}
+
+ dispatch(navigationNotAvailable(uuid));
};
+
export const navigateToNotFound = push(Routes.NO_MATCH);
export const navigateToRoot = push(Routes.ROOT);
export const navigateToProcessLogs = compose(push, getProcessLogUrl);
export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
- const usr = getState().auth.user;
- if (usr) {
- dispatch<any>(navigateTo(usr.uuid));
- }
+ navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
};
export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
//
// 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';
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"]`);
+ });
+
});
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 {
// causing compile issues.
export const getInitialResourceTypeFilters = pipe(
(): DataTableFilters => createTree<DataTableFilterItem>(),
- initFilter(ObjectTypeFilter.PROJECT),
+ pipe(
+ initFilter(ObjectTypeFilter.PROJECT),
+ initFilter(GroupTypeFilter.PROJECT, ObjectTypeFilter.PROJECT),
+ initFilter(GroupTypeFilter.FILTER_GROUP, ObjectTypeFilter.PROJECT),
+ ),
pipe(
initFilter(ObjectTypeFilter.PROCESS),
initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.PROCESS),
};
const serializeObjectTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => {
+ const groupFilters = getMatchingFilters(values(GroupTypeFilter), selectedFilters);
const collectionFilters = getMatchingFilters(values(CollectionTypeFilter), selectedFilters);
const processFilters = getMatchingFilters(values(ProcessTypeFilter), selectedFilters);
const typeFilters = pipe(
() => new Set(getMatchingFilters(values(ObjectTypeFilter), selectedFilters)),
+ set => groupFilters.length > 0
+ ? set.add(ObjectTypeFilter.PROJECT)
+ : set,
set => collectionFilters.length > 0
? set.add(ObjectTypeFilter.COLLECTION)
: set,
}
};
+const serializeGroupTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+ () => getMatchingFilters(values(GroupTypeFilter), selectedFilters),
+ filters => filters,
+ mappedFilters => ({
+ fb: buildGroupTypeFilters({ fb, filters: mappedFilters, use_prefix: true }),
+ selectedFilters
+ })
+)();
+
+const GROUP_TYPES = values(GroupTypeFilter);
+
+const buildGroupTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilder, filters: string[], use_prefix: boolean }) => {
+ switch (true) {
+ case filters.length === 0 || filters.length === GROUP_TYPES.length:
+ return fb;
+ case includes(GroupTypeFilter.PROJECT, filters):
+ return fb.addEqual('groups.group_class', 'project');
+ case includes(GroupTypeFilter.FILTER_GROUP, filters):
+ return fb.addEqual('groups.group_class', 'filter');
+ default:
+ return fb;
+ }
+};
+
const serializeProcessTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
() => getMatchingFilters(values(ProcessTypeFilter), selectedFilters),
filters => filters,
export const serializeResourceTypeFilters = pipe(
createFiltersBuilder,
serializeObjectTypeFilters,
+ serializeGroupTypeFilters,
serializeCollectionTypeFilters,
serializeProcessTypeFilters,
({ fb }) => fb.getFilters(),
}
}
return fb;
-};
\ No newline at end of file
+};
import { ResourceKind } from '~/models/resource';
import { GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
import { GroupClass } from '~/models/group';
+import { CategoriesListReducer } from '~/common/plugintypes';
+import { pluginConfig } from '~/plugins';
export enum SidePanelTreeCategory {
PROJECTS = 'Projects',
return [];
};
-const SIDE_PANEL_CATEGORIES = [
+let SIDE_PANEL_CATEGORIES: string[] = [
+ SidePanelTreeCategory.PROJECTS,
+ SidePanelTreeCategory.SHARED_WITH_ME,
SidePanelTreeCategory.PUBLIC_FAVORITES,
SidePanelTreeCategory.FAVORITES,
SidePanelTreeCategory.WORKFLOWS,
SidePanelTreeCategory.ALL_PROCESSES,
- SidePanelTreeCategory.TRASH,
+ SidePanelTreeCategory.TRASH
];
+const reduceCatsFn: (a: string[],
+ b: CategoriesListReducer) => string[] = (a, b) => b(a);
+
+SIDE_PANEL_CATEGORIES = pluginConfig.sidePanelCategories.reduce(reduceCatsFn, SIDE_PANEL_CATEGORIES);
+
export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id);
+
export const initSidePanelTree = () =>
(dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => {
const rootProjectUuid = getUserUuid(getState());
if (!rootProjectUuid) { return; }
- const nodes = SIDE_PANEL_CATEGORIES.map(id => initTreeNode({ id, value: id }));
- const projectsNode = initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
- const sharedNode = initTreeNode({ id: SidePanelTreeCategory.SHARED_WITH_ME, value: SidePanelTreeCategory.SHARED_WITH_ME });
+ const nodes = SIDE_PANEL_CATEGORIES.map(id => {
+ if (id === SidePanelTreeCategory.PROJECTS) {
+ return initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
+ } else {
+ return initTreeNode({ id, value: id });
+ }
+ });
dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
id: '',
pickerId: SIDE_PANEL_TREE,
- nodes: [projectsNode, sharedNode, ...nodes]
+ nodes
}));
SIDE_PANEL_CATEGORIES.forEach(category => {
- dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
- id: category,
- pickerId: SIDE_PANEL_TREE,
- nodes: []
- }));
+ if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.SHARED_WITH_ME) {
+ dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+ id: category,
+ pickerId: SIDE_PANEL_TREE,
+ nodes: []
+ }));
+ }
});
};
const params = {
filters: `[${new FilterBuilder()
.addIsA('uuid', ResourceKind.PROJECT)
- .addEqual('group_class', GroupClass.PROJECT)
+ .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
.addDistinct('uuid', getState().auth.config.uuidPrefix + '-j7d0g-publicfavorites')
.getFilters()}]`,
order: new OrderBuilder<ProjectResource>()
// SPDX-License-Identifier: AGPL-3.0
import { Dispatch } from 'redux';
-import { isSidePanelTreeCategory, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
-import { navigateToFavorites, navigateTo, navigateToTrash, navigateToSharedWithMe, navigateToWorkflows, navigateToPublicFavorites, navigateToAllProcesses } from '~/store/navigation/navigation-action';
-import {snackbarActions, SnackbarKind} from '~/store/snackbar/snackbar-actions';
+import { navigateTo } from '~/store/navigation/navigation-action';
export const navigateFromSidePanel = (id: string) =>
(dispatch: Dispatch) => {
- if (isSidePanelTreeCategory(id)) {
- dispatch<any>(getSidePanelTreeCategoryAction(id));
- } else {
- dispatch<any>(navigateTo(id));
- }
+ dispatch<any>(navigateTo(id));
};
-
-const getSidePanelTreeCategoryAction = (id: string) => {
- switch (id) {
- case SidePanelTreeCategory.FAVORITES:
- return navigateToFavorites;
- case SidePanelTreeCategory.PUBLIC_FAVORITES:
- return navigateToPublicFavorites;
- case SidePanelTreeCategory.TRASH:
- return navigateToTrash;
- case SidePanelTreeCategory.SHARED_WITH_ME:
- return navigateToSharedWithMe;
- case SidePanelTreeCategory.WORKFLOWS:
- return navigateToWorkflows;
- case SidePanelTreeCategory.ALL_PROCESSES:
- return navigateToAllProcesses;
- default:
- return sidePanelTreeCategoryNotAvailable(id);
- }
-};
-
-const sidePanelTreeCategoryNotAvailable = (id: string) =>
- snackbarActions.OPEN_SNACKBAR({
- message: `${id} not available`,
- hideDuration: 3000,
- kind: SnackbarKind.ERROR
- });
import { SUBPROCESS_PANEL_ID } from '~/store/subprocess-panel/subprocess-panel-actions';
import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action';
import { Config } from '~/common/config';
+import { pluginConfig } from '~/plugins';
+import { MiddlewareListReducer } from '~/common/plugintypes';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
return next(action);
};
- const middlewares: Middleware[] = [
+ let middlewares: Middleware[] = [
routerMiddleware(history),
thunkMiddleware.withExtraArgument(services),
authMiddleware(services),
subprocessMiddleware,
];
+ const reduceMiddlewaresFn: (a: Middleware[],
+ b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
+
+ middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares);
+
const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares));
return createStore(rootReducer, enhancer);
}
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 }>(),
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 }));
dispatch<any>(receiveTreePickerData<GroupContentsResource>({
id,
pickerId,
- data: items,
+ data: items.filter((item) => {
+ if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+ return false;
+ }
+ return true;
+ }),
extractNodeData: item => ({
id: item.uuid,
value: item,
setSidePanelBreadcrumbs,
setTrashBreadcrumbs
} from '~/store/breadcrumbs/breadcrumbs-actions';
-import { navigateTo } from '~/store/navigation/navigation-action';
+import { navigateTo, navigateToRootProject } from '~/store/navigation/navigation-action';
import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
import { ServiceRepository } from '~/services/services';
import { getResource } from '~/store/resources/resources';
return progress ? progress.working : false;
};
-const handleFirstTimeLoad = (action: any) =>
+export const handleFirstTimeLoad = (action: any) =>
async (dispatch: Dispatch<any>, getState: () => RootState) => {
try {
await dispatch(action);
if (router.location) {
const match = matchRootRoute(router.location.pathname);
if (match) {
- dispatch<any>(navigateTo(user.uuid));
+ dispatch<any>(navigateToRootProject);
}
}
} else {
import { openRemoveProcessDialog } from "~/store/processes/processes-actions";
import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
-export const processResourceActionSet: ContextMenuActionSet = [[
- {
- icon: RenameIcon,
- name: "Edit process",
- execute: (dispatch, resource) => {
- dispatch<any>(openProcessUpdateDialog(resource));
- }
- },
- {
- icon: ShareIcon,
- name: "Share",
- execute: (dispatch, { uuid }) => {
- dispatch<any>(openSharingDialog(uuid));
- }
- },
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
{
component: ToggleFavoriteAction,
execute: (dispatch, resource) => {
});
}
},
- {
- icon: MoveToIcon,
- name: "Move to",
- execute: (dispatch, resource) => {
- dispatch<any>(openMoveProcessDialog(resource));
- }
- },
{
icon: CopyIcon,
name: "Copy to project",
dispatch<any>(toggleDetailsPanel());
}
},
+]];
+
+export const processResourceActionSet: ContextMenuActionSet = [[
+ ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+ {
+ icon: RenameIcon,
+ name: "Edit process",
+ execute: (dispatch, resource) => {
+ dispatch<any>(openProcessUpdateDialog(resource));
+ }
+ },
+ {
+ icon: ShareIcon,
+ name: "Share",
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(openSharingDialog(uuid));
+ }
+ },
+ {
+ icon: MoveToIcon,
+ name: "Move to",
+ execute: (dispatch, resource) => {
+ dispatch<any>(openMoveProcessDialog(resource));
+ }
+ },
{
name: "Remove",
icon: RemoveIcon,
//
// 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', () => {
.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));
+ })
+ });
+});
},
]];
-export const projectActionSet: ContextMenuActionSet = [
+export const filterGroupActionSet: ContextMenuActionSet = [
[
...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
- {
- icon: NewProjectIcon,
- name: "New project",
- execute: (dispatch, resource) => {
- dispatch<any>(openProjectCreateDialog(resource.uuid));
- }
- },
{
icon: RenameIcon,
name: "Edit project",
},
]
];
+
+export const projectActionSet: ContextMenuActionSet = [
+ [
+ ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+ {
+ icon: NewProjectIcon,
+ name: "New project",
+ execute: (dispatch, resource) => {
+ dispatch<any>(openProjectCreateDialog(resource.uuid));
+ }
+ },
+ ]
+];
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), []),
}
}
]];
+
+export const filterGroupAdminActionSet: ContextMenuActionSet = [[
+ ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+ {
+ component: TogglePublicFavoriteAction,
+ name: 'TogglePublicFavoriteAction',
+ execute: (dispatch, resource) => {
+ dispatch<any>(togglePublicFavorite(resource)).then(() => {
+ dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+ });
+ }
+ }
+]];
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",
PROCESS = "Process",
PROCESS_ADMIN = 'ProcessAdmin',
PROCESS_RESOURCE = 'ProcessResource',
+ READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
PROCESS_LOGS = "ProcessLogs",
REPOSITORY = "Repository",
SSH_KEY = "SshKey",
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';
import { CollectionResource } from '~/models/collection';
import { IllegalNamingWarning } from '~/components/warning/warning';
import { loadResource } from '~/store/resources/resources-actions';
+import { GroupClass } from '~/models/group';
const renderName = (dispatch: Dispatch, item: GroupContentsResource) =>
<Grid container alignItems="center" wrap="nowrap" spacing={16}>
const renderIcon = (item: GroupContentsResource) => {
switch (item.kind) {
case ResourceKind.PROJECT:
+ if (item.groupClass === GroupClass.FILTER) {
+ return <FilterGroupIcon />;
+ }
return <ProjectIcon />;
case ResourceKind.COLLECTION:
if (item.uuid === item.currentVersionUuid) {
</Typography>;
});
-const renderType = (type: string) =>
+const renderType = (type: string, subtype: string) =>
<Typography noWrap>
- {resourceLabel(type)}
+ {resourceLabel(type, subtype)}
</Typography>;
export const ResourceType = connect(
(state: RootState, props: { uuid: string }) => {
const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
- return { type: resource ? resource.kind : '' };
- })((props: { type: string }) => renderType(props.type));
+ return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' };
+ })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype));
export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
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';
import { Dispatch } from 'redux';
import { getPropertyChip } from '../resource-properties-form/property-chip';
import { ResourceOwnerWithName } from '../data-explorer/renderers';
+import { GroupClass } from "~/models/group";
export class ProjectDetails extends DetailsData<ProjectResource> {
getIcon(className?: string) {
+ if (this.item.groupClass === GroupClass.FILTER) {
+ return <FilterGroupIcon className={className} />;
+ }
return <ProjectIcon className={className} />;
}
const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
withStyles(styles)(
({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
- <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
+ <DetailsAttribute label='Type' value={project.groupClass === GroupClass.FILTER ? 'Filter group' : resourceLabel(ResourceKind.PROJECT)} />
<DetailsAttribute label='Owner' linkToUuid={project.ownerUuid}
uuidEnhancer={(uuid: string) => <ResourceOwnerWithName uuid={uuid} />} />
<DetailsAttribute label='Last modified' value={formatDate(project.modifiedAt)} />
<DetailsAttribute label='Created at' value={formatDate(project.createdAt)} />
- <DetailsAttribute label='Project UUID' linkToUuid={project.uuid} value={project.uuid} />
+ <DetailsAttribute label='UUID' linkToUuid={project.uuid} value={project.uuid} />
<DetailsAttribute label='Description'>
{project.description ?
<RichTextEditorLink
}
</DetailsAttribute>
<DetailsAttribute label='Properties'>
- <div onClick={onClick}>
- <RenameIcon className={classes.editIcon} />
- </div>
+ {project.groupClass !== GroupClass.FILTER ?
+ <div onClick={onClick}>
+ <RenameIcon className={classes.editIcon} />
+ </div>
+ : ''
+ }
</DetailsAttribute>
{
Object.keys(project.properties).map(k =>
interface ProjectNameFieldProps {
validate: Validator[];
+ label?: string;
}
// Validation behavior depends on the value of ForwardSlashNameSubstitution.
name='name'
component={TextField}
validate={props.validate}
- label="Project Name"
+ label={props.label || "Project Name"}
autoFocus={true} /></span>
);
navigateToLinkAccount
} from '~/store/navigation/navigation-action';
import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
+import { pluginConfig } from '~/plugins';
+import { ElementListReducer } from '~/common/plugintypes';
interface AccountMenuProps {
user?: User;
});
export const AccountMenuComponent =
- ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) =>
- user
- ? <DropdownMenu
- icon={<UserPanelIcon />}
- id="account-menu"
- title="Account Management"
- key={currentRoute}>
- <MenuItem disabled>
- {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
- </MenuItem>
- {user.isActive ? <>
- <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
- {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
- <MenuItem onClick={() => {
- dispatch<any>(getNewExtraToken(true));
- dispatch(openTokenDialog);
- }}>Get API token</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
- <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
- </> : null}
+ ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) => {
+ let accountMenuItems = <>
+ <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
+ <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+ <MenuItem onClick={() => {
+ dispatch<any>(getNewExtraToken(true));
+ dispatch(openTokenDialog);
+ }}>Get API token</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+ <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
<MenuItem>
<a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
className={classes.link}>
Switch to Workbench v1</a></MenuItem>
- <Divider />
- <MenuItem data-cy="logout-menuitem"
- onClick={() => dispatch(authActions.LOGOUT({deleteLinkData: true}))}>
- Logout
- </MenuItem>
- </DropdownMenu>
- : null;
+ </>;
-export const AccountMenu = withStyles(styles)( connect(mapStateToProps)(AccountMenuComponent) );
+ const reduceItemsFn: (a: React.ReactElement[],
+ b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+
+ accountMenuItems = React.createElement(React.Fragment, null,
+ pluginConfig.accountMenuList.reduce(reduceItemsFn, React.Children.toArray(accountMenuItems.props.children)));
+
+ return user
+ ? <DropdownMenu
+ icon={<UserPanelIcon />}
+ id="account-menu"
+ title="Account Management"
+ key={currentRoute}>
+ <MenuItem disabled>
+ {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
+ </MenuItem>
+ {user.isActive && accountMenuItems}
+ <Divider />
+ <MenuItem data-cy="logout-menuitem"
+ onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true }))}>
+ Logout
+ </MenuItem>
+ </DropdownMenu>
+ : null;
+ };
+
+export const AccountMenu = withStyles(styles)(connect(mapStateToProps)(AccountMenuComponent));
import { HelpMenu } from '~/views-components/main-app-bar/help-menu';
import { ReactNode } from "react";
import { AdminMenu } from "~/views-components/main-app-bar/admin-menu";
+import { pluginConfig } from '~/plugins';
type CssRules = 'toolbar' | 'link';
return <AppBar position="absolute">
<Toolbar className={props.classes.toolbar}>
<Grid container justify="space-between">
- <Grid container item xs={3} direction="column" justify="center">
+ {pluginConfig.appBarLeft || <Grid container item xs={3} direction="column" justify="center">
<Typography variant='h6' color="inherit" noWrap>
<Link to={Routes.ROOT} className={props.classes.link}>
<span dangerouslySetInnerHTML={{ __html: props.siteBanner }} /> ({props.uuidPrefix})
- </Link>
+ </Link>
</Typography>
<Typography variant="caption" color="inherit">{props.buildInfo}</Typography>
- </Grid>
+ </Grid>}
<Grid
item
xs={6}
container
alignItems="center">
- {props.user && props.user.isActive && <SearchBar />}
+ {pluginConfig.appBarMiddle || (props.user && props.user.isActive && <SearchBar />)}
</Grid>
<Grid
item
alignItems="center"
justify="flex-end"
wrap="nowrap">
- {props.user
- ? <>
- <NotificationsMenu />
- <AccountMenu />
- {props.user.isAdmin && <AdminMenu />}
- <HelpMenu />
- </>
- : <HelpMenu />}
+ {props.user ? <>
+ <NotificationsMenu />
+ <AccountMenu />
+ {pluginConfig.appBarRight ||
+ <>
+ {props.user.isAdmin && <AdminMenu />}
+ <HelpMenu />
+ </>}
+ </> :
+ pluginConfig.appBarRight || <HelpMenu />
+ }
</Grid>
</Grid>
</Toolbar>
const isButtonVisible = ({ router }: RootState) => {
const pathname = router.location ? router.location.pathname : '';
- return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) &&
- !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) &&
- !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) &&
- !Routes.matchSiteManagerRoute(pathname) &&
- !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) &&
- !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) &&
- !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname);
+ return Routes.matchCollectionsContentAddressRoute(pathname) ||
+ Routes.matchPublicFavoritesRoute(pathname) ||
+ Routes.matchGroupDetailsRoute(pathname) ||
+ Routes.matchGroupsRoute(pathname) ||
+ Routes.matchUsersRoute(pathname) ||
+ Routes.matchSearchResultsRoute(pathname) ||
+ Routes.matchSharedWithMeRoute(pathname) ||
+ Routes.matchProcessRoute(pathname) ||
+ Routes.matchCollectionRoute(pathname) ||
+ Routes.matchProjectRoute(pathname) ||
+ Routes.matchAllProcessesRoute(pathname) ||
+ Routes.matchTrashRoute(pathname) ||
+ Routes.matchFavoritesRoute(pathname);
+
+ /* return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) &&
+ * !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) &&
+ * !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) &&
+ * !Routes.matchSiteManagerRoute(pathname) &&
+ * !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) &&
+ * !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) &&
+ * !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname); */
};
export const MainContentBar =
handleDelete: (key: string, value: string) => () => dispatch<any>(deleteProjectProperty(key, value)),
});
-type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
+type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToProps)(
withStyles(styles)(
- withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
- ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
- <Dialog open={open}
- onClose={closeDialog}
- fullWidth
- maxWidth='sm'>
- <DialogTitle>Properties</DialogTitle>
- <DialogContent>
- <ProjectPropertiesForm />
- {project && project.properties &&
- Object.keys(project.properties).map(k =>
- Array.isArray(project.properties[k])
- ? project.properties[k].map((v: string) =>
- getPropertyChip(
- k, v,
- handleDelete(k, v),
- classes.tag))
- : getPropertyChip(
- k, project.properties[k],
- handleDelete(k, project.properties[k]),
- classes.tag)
- )
- }
- </DialogContent>
- <DialogActions>
- <Button
- variant='text'
- color='primary'
- onClick={closeDialog}>
- Close
+ withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
+ ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
+ <Dialog open={open}
+ onClose={closeDialog}
+ fullWidth
+ maxWidth='sm'>
+ <DialogTitle>Properties</DialogTitle>
+ <DialogContent>
+ <ProjectPropertiesForm />
+ {project && project.properties &&
+ Object.keys(project.properties).map(k =>
+ Array.isArray(project.properties[k])
+ ? project.properties[k].map((v: string) =>
+ getPropertyChip(
+ k, v,
+ handleDelete(k, v),
+ classes.tag))
+ : getPropertyChip(
+ k, project.properties[k],
+ handleDelete(k, project.properties[k]),
+ classes.tag)
+ )
+ }
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='text'
+ color='primary'
+ onClick={closeDialog}>
+ Close
</Button>
- </DialogActions>
- </Dialog>
- )
-));
\ No newline at end of file
+ </DialogActions>
+ </Dialog>
+ )
+ ));
const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
const filterGroups = new FilterBuilder()
- .addNotIn('group_class', [GroupClass.PROJECT])
+ .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
.addILike('name', value)
.getFilters();
import { runProcessPanelActions } from '~/store/run-process-panel/run-process-panel-actions';
import { getUserUuid } from '~/common/getuser';
import { matchProjectRoute } from '~/routes/routes';
-import { GroupResource } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
import { ResourcesState, getResource } from '~/store/resources/resources';
import { extractUuidKind, ResourceKind } from '~/models/resource';
+import { pluginConfig } from '~/plugins';
+import { ElementListReducer } from '~/common/plugintypes';
+import { Location } from 'history';
type CssRules = 'button' | 'menuItem' | 'icon';
});
interface SidePanelDataProps {
- location: any;
+ location: Location;
currentItemId: string;
resources: ResourcesState;
currentUserUUID: string | undefined;
const currentProject = getResource<GroupResource>(currentItemId)(resources);
if (currentProject &&
currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
- !isProjectTrashed(currentProject, resources)) {
+ !isProjectTrashed(currentProject, resources) &&
+ currentProject.groupClass !== GroupClass.FILTER) {
enabled = true;
}
}
+
+ for (const enableFn of pluginConfig.enableNewButtonMatchers) {
+ if (enableFn(location, currentItemId, currentUserUUID, resources)) {
+ enabled = true;
+ }
+ }
+
+ let menuItems = <>
+ <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
+ <CollectionIcon className={classes.icon} /> New collection
+ </MenuItem>
+ <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
+ <ProcessIcon className={classes.icon} /> Run a process
+ </MenuItem>
+ <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
+ <ProjectIcon className={classes.icon} /> New project
+ </MenuItem>
+ </>;
+
+ const reduceItemsFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] =
+ (a, b) => b(a, classes.menuItem);
+
+ menuItems = React.createElement(React.Fragment, null,
+ pluginConfig.newButtonMenuList.reduce(reduceItemsFn, React.Children.toArray(menuItems.props.children)));
+
return <Toolbar>
<Grid container>
<Grid container item xs alignItems="center" justify="flex-start">
onClose={this.handleClose}
onClick={this.handleClose}
transformOrigin={transformOrigin}>
- <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
- <CollectionIcon className={classes.icon} /> New collection
- </MenuItem>
- <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
- <ProcessIcon className={classes.icon} /> Run a process
- </MenuItem>
- <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
- <ProjectIcon className={classes.icon} /> New project
- </MenuItem>
+ {menuItems}
</Menu>
</Grid>
</Grid>
}
}
)
-);
\ No newline at end of file
+);
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;
const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
typeof item.data === 'string'
? getSidePanelIcon(item.data)
- : ProjectIcon;
+ : (item.data && item.data.groupClass === GroupClass.FILTER)
+ ? FilterGroupIcon
+ : ProjectIcon;
const getSidePanelIcon = (category: string) => {
switch (category) {
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";
handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
const { resources } = this.props;
const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
- const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+ // When viewing the contents of a filter group, all contents should be treated as read only.
+ let readonly = false;
+ const project = getResource<GroupResource>(this.props.currentItemId)(resources);
+ if (project && project.groupClass === GroupClass.FILTER) {
+ readonly = true;
+ }
+
+ const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
if (menuKind && resource) {
this.props.dispatch<any>(openContextMenu(event, {
name: resource.name,
import { AutoLogout } from '~/views-components/auto-logout/auto-logout';
import { RestoreCollectionVersionDialog } from '~/views-components/collections-dialog/restore-version-dialog';
import { WebDavS3InfoDialog } from '~/views-components/webdav-s3-dialog/webdav-s3-dialog';
+import { pluginConfig } from '~/plugins';
+import { ElementListReducer } from '~/common/plugintypes';
type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
+let routes = <>
+ <Route path={Routes.PROJECTS} component={ProjectPanel} />
+ <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
+ <Route path={Routes.FAVORITES} component={FavoritePanel} />
+ <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
+ <Route path={Routes.PROCESSES} component={ProcessPanel} />
+ <Route path={Routes.TRASH} component={TrashPanel} />
+ <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
+ <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
+ <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
+ <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
+ <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
+ <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
+ <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
+ <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
+ <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
+ <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyPanel} />
+ <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
+ <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
+ <Route path={Routes.USERS} component={UserPanel} />
+ <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
+ <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
+ <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
+ <Route path={Routes.GROUPS} component={GroupsPanel} />
+ <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
+ <Route path={Routes.LINKS} component={LinkPanel} />
+ <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
+ <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
+ <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
+</>;
+
+const reduceRoutesFn: (a: React.ReactElement[],
+ b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+
+routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children)));
+
export const WorkbenchPanel =
withStyles(styles)((props: WorkbenchPanelProps) =>
<Grid container item xs className={props.classes.root}>
</Grid>
<Grid item xs className={props.classes.content}>
<Switch>
- <Route path={Routes.PROJECTS} component={ProjectPanel} />
- <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
- <Route path={Routes.FAVORITES} component={FavoritePanel} />
- <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
- <Route path={Routes.PROCESSES} component={ProcessPanel} />
- <Route path={Routes.TRASH} component={TrashPanel} />
- <Route path={Routes.PROCESS_LOGS} component={ProcessLogPanel} />
- <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
- <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
- <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
- <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
- <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
- <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
- <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
- <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
- <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyPanel} />
- <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
- <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
- <Route path={Routes.USERS} component={UserPanel} />
- <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
- <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
- <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
- <Route path={Routes.GROUPS} component={GroupsPanel} />
- <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
- <Route path={Routes.LINKS} component={LinkPanel} />
- <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
- <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
- <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
+ {routes}
<Route path={Routes.NO_MATCH} component={NotFoundPanel} />
</Switch>
</Grid>
<VirtualMachineAttributesDialog />
<FedLogin />
<WebDavS3InfoDialog />
+ {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
</Grid>
);