TARGETS?="centos7 debian8 debian10 ubuntu1404 ubuntu1604 ubuntu1804 ubuntu2004"
+ARVADOS_DIRECTORY?=unset
+
DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for big data science.
MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
integration-tests: yarn-install
yarn run cypress install
- $(WORKSPACE)/tools/run-integration-tests.sh
+ $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY)
integration-tests-in-docker: workbench2-build-image
docker run -ti -v$(PWD):$(PWD) -w$(PWD) workbench2-build make integration-tests
beforeEach(function () {
cy.clearCookies()
cy.clearLocalStorage()
- })
-
- it('checks that Public favorites does not appear under shared with me', function () {
- cy.loginAs(adminUser);
- cy.contains('Shared with me').click();
- cy.get('main').contains('Public favorites').should('not.exist');
});
it('creates and removes a public favorite', function () {
cy.loginAs(adminUser);
+
cy.createGroup(adminUser.token, {
name: `my-favorite-project`,
group_class: 'project',
});
});
- it('can copy collection to favorites', () => {
+ it('can copy selected into the collection', () => {
cy.loginAs(adminUser);
- cy.createGroup(adminUser.token, {
- name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
- cy.contains('Refresh').click();
- cy.get('main').contains(mySharedWritableProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
+ cy.createCollection(adminUser.token, {
+ name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
+ manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+ })
+ .as('testSourceCollection').then(function (testSourceCollection) {
+ cy.shareWith(adminUser.token, activeUser.user.uuid, testSourceCollection.uuid, 'can_read');
});
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
- cy.createGroup(adminUser.token, {
- name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
- cy.contains('Refresh').click();
- cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
+ cy.createCollection(adminUser.token, {
+ name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
+ })
+ .as('testTargetCollection').then(function (testTargetCollection) {
+ cy.shareWith(adminUser.token, activeUser.user.uuid, testTargetCollection.uuid, 'can_write');
});
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
- cy.createGroup(activeUser.token, {
- name: `my-project ${Math.floor(Math.random() * 999999)}`,
- group_class: 'project',
- }).as('myProject1');
+ cy.getAll('@testSourceCollection', '@testTargetCollection')
+ .then(function ([testSourceCollection, testTargetCollection]) {
+ cy.loginAs(activeUser);
+
+ cy.get('.layout-pane-primary')
+ .contains('Projects').click();
+
+ cy.addToFavorites(activeUser.token, activeUser.user.uuid, testTargetCollection.uuid);
+
+ cy.get('main').contains(testSourceCollection.name).click();
+ cy.get('[data-cy=collection-files-panel]').contains('bar');
+ cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
+ cy.get('[data-cy=collection-files-panel-options-btn]').click();
+ cy.get('[data-cy=context-menu]')
+ .contains('Copy selected into the collection').click();
+
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+ .find('i')
+ .click();
+
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+ .contains(testTargetCollection.name)
+ .click();
+
+ cy.get('[data-cy=form-submit-btn]').click();
+
+ cy.get('.layout-pane-primary')
+ .contains('Projects').click();
+
+ cy.get('main').contains(testTargetCollection.name).click();
+
+ cy.get('[data-cy=collection-files-panel]').contains('bar');
+ });
+ });
+
+ it('can copy collection to favorites', () => {
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedWritableProject',
+ canWrite: true,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedReadonlyProject',
+ canWrite: false,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: activeUser,
+ projectName: 'myProject1',
+ addToFavorites: true
+ });
cy.createCollection(adminUser.token, {
name: `Test collection ${Math.floor(Math.random() * 999999)}`,
.then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1, testCollection]) {
cy.loginAs(activeUser);
- cy.contains('Shared with me').click();
-
- cy.get('main').contains(mySharedWritableProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
- cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
cy.doSearch(`${activeUser.user.uuid}`);
- cy.get('main').contains(myProject1.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
cy.contains(testCollection.name).rightclick();
cy.get('[data-cy=context-menu]').within(() => {
cy.contains('Move to').click();
});
});
- it('can copy selected into the collection', () => {
- cy.loginAs(adminUser);
-
- cy.createCollection(adminUser.token, {
- name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
- manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
- })
- .as('testSourceCollection').then(function (testSourceCollection) {
- cy.contains('Refresh').click();
- cy.get('main').contains(testSourceCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
- });
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
-
- cy.createCollection(adminUser.token, {
- name: `Test target collection ${Math.floor(Math.random() * 999999)}`,
- })
- .as('testTargetCollection').then(function (testTargetCollection) {
- cy.contains('Refresh').click();
- cy.get('main').contains(testTargetCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Share').click();
- });
- cy.get('[id="select-permissions"]').as('selectPermissions');
- cy.get('@selectPermissions').click();
- cy.contains('Write').click();
- cy.get('.sharing-dialog').as('sharingDialog');
- cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
- cy.get('[role=tooltip]').click();
- cy.get('@sharingDialog').contains('Save').click();
- });
+ it('can view favourites in workflow', () => {
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedWritableProject',
+ canWrite: true,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: adminUser,
+ targetUser: activeUser,
+ projectName: 'mySharedReadonlyProject',
+ canWrite: false,
+ addToFavorites: true
+ });
+ cy.createProject({
+ owningUser: activeUser,
+ projectName: 'myProject1',
+ addToFavorites: true
+ });
- cy.getAll('@testSourceCollection', '@testTargetCollection')
- .then(function ([testSourceCollection, testTargetCollection]) {
+ cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject', '@myProject1')
+ .then(function ([mySharedWritableProject, mySharedReadonlyProject, myProject1]) {
cy.loginAs(activeUser);
- cy.get('.layout-pane-primary')
- .contains('Projects').click();
-
- cy.get('main').contains(testTargetCollection.name).rightclick();
- cy.get('[data-cy=context-menu]').within(() => {
- cy.contains('Add to favorites').click();
- });
-
- cy.get('main').contains(testSourceCollection.name).click();
- cy.get('[data-cy=collection-files-panel]').contains('bar');
- cy.get('[data-cy=collection-files-panel]').find('input[type=checkbox]').click({ force: true });
- cy.get('[data-cy=collection-files-panel-options-btn]').click();
- cy.get('[data-cy=context-menu]')
- .contains('Copy selected into the collection').click();
+ cy.createWorkflow(adminUser.token, {
+ name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+ definition: "{\n \"$graph\": [\n {\n \"class\": \"Workflow\",\n \"doc\": \"Reverse the lines in a document, then sort those lines.\",\n \"hints\": [\n {\n \"acrContainerImage\": \"99b0201f4cade456b4c9d343769a3b70+261\",\n \"class\": \"http://arvados.org/cwl#WorkflowRunnerResources\"\n }\n ],\n \"id\": \"#main\",\n \"inputs\": [\n {\n \"default\": null,\n \"doc\": \"The input file to be processed.\",\n \"id\": \"#main/input\",\n \"type\": \"File\"\n },\n {\n \"default\": true,\n \"doc\": \"If true, reverse (decending) sort\",\n \"id\": \"#main/reverse_sort\",\n \"type\": \"boolean\"\n }\n ],\n \"outputs\": [\n {\n \"doc\": \"The output with the lines reversed and sorted.\",\n \"id\": \"#main/output\",\n \"outputSource\": \"#main/sorted/output\",\n \"type\": \"File\"\n }\n ],\n \"steps\": [\n {\n \"id\": \"#main/rev\",\n \"in\": [\n {\n \"id\": \"#main/rev/input\",\n \"source\": \"#main/input\"\n }\n ],\n \"out\": [\n \"#main/rev/output\"\n ],\n \"run\": \"#revtool.cwl\"\n },\n {\n \"id\": \"#main/sorted\",\n \"in\": [\n {\n \"id\": \"#main/sorted/input\",\n \"source\": \"#main/rev/output\"\n },\n {\n \"id\": \"#main/sorted/reverse\",\n \"source\": \"#main/reverse_sort\"\n }\n ],\n \"out\": [\n \"#main/sorted/output\"\n ],\n \"run\": \"#sorttool.cwl\"\n }\n ]\n },\n {\n \"baseCommand\": \"rev\",\n \"class\": \"CommandLineTool\",\n \"doc\": \"Reverse each line using the `rev` command\",\n \"hints\": [\n {\n \"class\": \"ResourceRequirement\",\n \"ramMin\": 8\n }\n ],\n \"id\": \"#revtool.cwl\",\n \"inputs\": [\n {\n \"id\": \"#revtool.cwl/input\",\n \"inputBinding\": {},\n \"type\": \"File\"\n }\n ],\n \"outputs\": [\n {\n \"id\": \"#revtool.cwl/output\",\n \"outputBinding\": {\n \"glob\": \"output.txt\"\n },\n \"type\": \"File\"\n }\n ],\n \"stdout\": \"output.txt\"\n },\n {\n \"baseCommand\": \"sort\",\n \"class\": \"CommandLineTool\",\n \"doc\": \"Sort lines using the `sort` command\",\n \"hints\": [\n {\n \"class\": \"ResourceRequirement\",\n \"ramMin\": 8\n }\n ],\n \"id\": \"#sorttool.cwl\",\n \"inputs\": [\n {\n \"id\": \"#sorttool.cwl/reverse\",\n \"inputBinding\": {\n \"position\": 1,\n \"prefix\": \"-r\"\n },\n \"type\": \"boolean\"\n },\n {\n \"id\": \"#sorttool.cwl/input\",\n \"inputBinding\": {\n \"position\": 2\n },\n \"type\": \"File\"\n }\n ],\n \"outputs\": [\n {\n \"id\": \"#sorttool.cwl/output\",\n \"outputBinding\": {\n \"glob\": \"output.txt\"\n },\n \"type\": \"File\"\n }\n ],\n \"stdout\": \"output.txt\"\n }\n ],\n \"cwlVersion\": \"v1.0\"\n}",
+ owner_uuid: myProject1.uuid,
+ })
+ .as('testWorkflow');
- cy.get('[data-cy=projects-tree-favourites-tree-picker]')
- .find('i')
- .click();
+ cy.contains('Shared with me').click();
- cy.get('[data-cy=projects-tree-favourites-tree-picker]')
- .contains(testTargetCollection.name)
- .click();
+ cy.doSearch(`${activeUser.user.uuid}`);
- cy.get('[data-cy=form-submit-btn]').click();
+ cy.get('main').contains(myProject1.name).click();
- cy.get('.layout-pane-primary')
- .contains('Projects').click();
+ cy.get('[data-cy=side-panel-button]').click();
- cy.get('main').contains(testTargetCollection.name).click();
+ cy.get('#aside-menu-list').contains('Run a process').click();
- cy.get('[data-cy=collection-files-panel]').contains('bar');
+ cy.get('@testWorkflow')
+ .then((testWorkflow) => {
+ cy.get('main').contains(testWorkflow.name).click();
+ cy.get('[data-cy=run-process-next-button]').click();
+ cy.get('[readonly]').click();
+ cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
+ cy.get('[data-cy=projects-tree-favourites-tree-picker]').contains('Favorites').closest('ul').find('i').click();
+ cy.get('@chooseFileDialog').find(`[data-id=${mySharedWritableProject.uuid}]`);
+ cy.get('@chooseFileDialog').find(`[data-id=${mySharedReadonlyProject.uuid}]`);
+ });
});
});
});
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Sharing tests', function () {
+ let activeUser;
+ let adminUser;
+
+ 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 () {
+ adminUser = this.adminUser;
+ }
+ );
+ cy.getUser('collectionuser1', 'Collection', 'User', false, true)
+ .as('activeUser').then(function () {
+ activeUser = this.activeUser;
+ }
+ );
+ })
+
+ beforeEach(function () {
+ cy.clearCookies()
+ cy.clearLocalStorage()
+ });
+
+ it('can share projects to other users', () => {
+ cy.loginAs(adminUser);
+
+ cy.createGroup(adminUser.token, {
+ name: `my-shared-writable-project ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as('mySharedWritableProject').then(function (mySharedWritableProject) {
+ cy.contains('Refresh').click();
+ cy.get('main').contains(mySharedWritableProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').within(() => {
+ cy.contains('Share').click();
+ });
+ cy.get('[id="select-permissions"]').as('selectPermissions');
+ cy.get('@selectPermissions').click();
+ cy.contains('Write').click();
+ cy.get('.sharing-dialog').as('sharingDialog');
+ cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+ cy.get('[role=tooltip]').click();
+ cy.get('@sharingDialog').contains('Save').click();
+ });
+
+ cy.createGroup(adminUser.token, {
+ name: `my-shared-readonly-project ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as('mySharedReadonlyProject').then(function (mySharedReadonlyProject) {
+ cy.contains('Refresh').click();
+ cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').within(() => {
+ cy.contains('Share').click();
+ });
+ cy.get('.sharing-dialog').as('sharingDialog');
+ cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
+ cy.get('[role=tooltip]').click();
+ cy.get('@sharingDialog').contains('Save').click();
+ });
+
+ cy.getAll('@mySharedWritableProject', '@mySharedReadonlyProject')
+ .then(function ([mySharedWritableProject, mySharedReadonlyProject]) {
+ cy.loginAs(activeUser);
+
+ cy.contains('Shared with me').click();
+
+ cy.get('main').contains(mySharedWritableProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').should('contain', 'Move to trash');
+ cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+
+ cy.get('main').contains(mySharedReadonlyProject.name).rightclick();
+ cy.get('[data-cy=context-menu]').should('not.contain', 'Move to trash');
+ });
+ });
+});
\ No newline at end of file
.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`);
+ })
+ })
+
+})
}
return promise
-})
\ No newline at end of file
+})
+
+Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => {
+ cy.createLink(srcUserToken, {
+ name: permission,
+ link_class: 'permission',
+ head_uuid: itemUUID,
+ tail_uuid: targetUserUUID
+ });
+})
+
+Cypress.Commands.add('addToFavorites', (activeUserToken, activeUserUUID, itemUUID) => {
+ cy.createLink(activeUserToken, {
+ head_uuid: itemUUID,
+ link_class: 'star',
+ name: '',
+ owner_uuid: activeUserUUID,
+ tail_uuid: activeUserUUID,
+ });
+})
+
+Cypress.Commands.add('createProject', ({
+ owningUser,
+ targetUser,
+ projectName,
+ canWrite,
+ addToFavorites
+}) => {
+ const writePermission = canWrite ? 'can_write' : 'can_read';
+
+ cy.createGroup(owningUser.token, {
+ name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
+ group_class: 'project',
+ }).as(`${projectName}`).then((project) => {
+ if (targetUser && targetUser !== owningUser) {
+ cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+ }
+ if (addToFavorites) {
+ const user = targetUser ? targetUser : owningUser;
+ cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+ }
+ });
+});
\ No newline at end of file
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";
className={classNames(classes.root, className)}>
{
lines.map((line: string, index: number) => {
- return <Typography key={index} className={apiResponse ? classes.space : ''} component="pre">{line}</Typography>;
+ return <Typography key={index} className={apiResponse ? classes.space : className} component="pre">{line}</Typography>;
})
}
</Typography>
}
render() {
- const { uuidEnhancer, label, link, value, children, classes, classLabel,
- classValue, lowercaseValue, onValueClick, linkToUuid,
- localCluster, remoteHostsConfig, sessions, copyValue } = this.props;
+ const { uuidEnhancer, link, value, classes, linkToUuid,
+ localCluster, remoteHostsConfig, sessions } = this.props;
let valueNode: React.ReactNode;
if (linkToUuid) {
- const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
+ const uuid = uuidEnhancer ? uuidEnhancer(linkToUuid) : linkToUuid;
const linkUrl = getNavUrl(linkToUuid || "", { localCluster, remoteHostsConfig, sessions });
if (linkUrl[0] === '/') {
valueNode = <Link to={linkUrl} className={classes.link}>{uuid}</Link>;
valueNode = value;
}
- return <Typography component="div" className={classes.attribute}>
- <Typography component="div" className={classnames([classes.label, classLabel])}>{label}</Typography>
- <Typography
- onClick={onValueClick}
- component="div"
- className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
- {valueNode}
- {children}
- {(linkToUuid || copyValue) && <Tooltip title="Copy to clipboard">
- <span className={classes.copyIcon}>
- <CopyToClipboard text={linkToUuid || copyValue || ""} onCopy={() => this.onCopy("Copied")}>
- <CopyIcon />
- </CopyToClipboard>
- </span>
- </Tooltip>}
- </Typography>
- </Typography>;
+ return <DetailsAttributeComponent {...this.props} value={valueNode} onCopy={this.onCopy} />;
}
}
));
+
+interface DetailsAttributeComponentProps {
+ value: React.ReactNode;
+ onCopy?: (msg: string) => void;
+}
+
+export const DetailsAttributeComponent = withStyles(styles)(
+ (props: DetailsAttributeDataProps & WithStyles<CssRules> & DetailsAttributeComponentProps) =>
+ <Typography component="div" className={props.classes.attribute}>
+ <Typography component="div" className={classnames([props.classes.label, props.classLabel])}>{props.label}</Typography>
+ <Typography
+ onClick={props.onValueClick}
+ component="div"
+ className={classnames([props.classes.value, props.classValue, { [props.classes.lowercaseValue]: props.lowercaseValue }])}>
+ {props.value}
+ {props.children}
+ {(props.linkToUuid || props.copyValue) && props.onCopy && <Tooltip title="Copy to clipboard">
+ <span className={props.classes.copyIcon}>
+ <CopyToClipboard text={props.linkToUuid || props.copyValue || ""} onCopy={() => props.onCopy!("Copied")}>
+ <CopyIcon />
+ </CopyToClipboard>
+ </span>
+ </Tooltip>}
+ </Typography>
+ </Typography>);
+
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>
| 'iconOpen'
| 'toggableIcon'
| 'checkbox'
+ | 'virtualFileTree'
| 'virtualizedList';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
list: {
padding: '3px 0px',
},
+ virtualFileTree: {
+ "&:last-child": {
+ paddingBottom: 20
+ }
+ },
virtualizedList: {
height: '200px',
},
const it = itemList[index];
const level = it.level || 0;
const { toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = treeProps;
- const { listItem, loader, toggableIconContainer, renderContainer } = classes;
+ const { listItem, loader, toggableIconContainer, renderContainer, virtualFileTree } = classes;
const { levelIndentation = 20, itemRightPadding = 20 } = treeProps;
const showSelection = typeof treeProps.showSelection === 'function'
: undefined;
};
- return <div data-cy='virtual-file-tree' style={style}>
+ return <div className={virtualFileTree} data-cy='virtual-file-tree' style={style}>
<ListItem button className={listItem}
style={{
paddingLeft: (level + 1) * levelIndentation,
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 { initWebSocket } from '~/websocket/websocket';
import { Config } from '~/common/config';
import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { processResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
+import { setTokenDialogApiHost } from '~/store/token-dialog/token-dialog-actions';
+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();
store.subscribe(initListener(history, store, services, config));
store.dispatch(initAuth(config));
store.dispatch(setBuildInfo());
- store.dispatch(setCurrentTokenDialogApiHost(apiHost));
+ store.dispatch(setTokenDialogApiHost(apiHost));
store.dispatch(loadVocabulary);
store.dispatch(loadFileViewersConfig);
ownerUuid: string;
defaultOwnerUuid: string;
scopes: string[];
-}
\ No newline at end of file
+}
+
+export const getTokenV2 = (aca: ApiClientAuthorization): string =>
+ `v2/${aca.uuid}/${aca.apiToken}`;
\ No newline at end of file
}
export enum GroupClass {
- PROJECT = "project"
+ PROJECT = 'project',
+ FILTER = 'filter',
}
import { GroupClass, GroupResource } from "./group";
export interface ProjectResource extends GroupResource {
- groupClass: GroupClass.PROJECT;
+ groupClass: GroupClass.PROJECT | GroupClass.FILTER;
}
export const getProjectUrl = (uuid: string) => {
});
}
- create(data?: Partial<T>) {
+ create(data?: Partial<T>, showErrors?: boolean) {
return CommonService.defaultResponse(
this.serverApi
.post<T>(this.resourceType, data && CommonService.mapKeys(_.snakeCase)(data)),
- this.actions
+ this.actions,
+ true, // mapKeys
+ showErrors
);
}
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()
)
});
//
// SPDX-License-Identifier: AGPL-3.0
-import { initAuth } from "./auth-action";
+import { getNewExtraToken, initAuth } from "./auth-action";
import { API_TOKEN_KEY } from "~/services/auth-service/auth-service";
import 'jest-localstorage-mock';
import { ServiceRepository, createServices } from "~/services/services";
import { configureStore, RootStore } from "../store";
import { createBrowserHistory } from "history";
-import { mockConfig, DISCOVERY_DOC_PATH, } from '~/common/config';
+import { Config, mockConfig } from '~/common/config';
import { ApiActions } from "~/services/api/api-actions";
import { ACCOUNT_LINK_STATUS_KEY } from '~/services/link-account-service/link-account-service';
import Axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { ImportMock } from 'ts-mock-imports';
import * as servicesModule from "~/services/services";
+import { SessionStatus } from "~/models/session";
describe('auth-actions', () => {
const axiosInst = Axios.create({ headers: {} });
importMocks.map(m => m.restore());
});
- it('should initialise state with user and api token from local storage', (done) => {
-
+ it('creates an extra token', async () => {
axiosMock
.onGet("/users/current")
.reply(200, {
is_active: true,
username: "jdoe",
prefs: {}
+ })
+ .onGet("/api_client_authorizations/current")
+ .reply(200, {
+ expires_at: "2140-01-01T00:00:00.000Z",
+ api_token: 'extra token',
+ })
+ .onPost("/api_client_authorizations")
+ .replyOnce(200, {
+ uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+ apiToken: 'extra token',
+ })
+ .onPost("/api_client_authorizations")
+ .reply(200, {
+ uuid: 'zzzzz-gj3su-xxxxxxxxxx',
+ apiToken: 'extra additional token',
});
+ importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+ sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+ localStorage.setItem(API_TOKEN_KEY, "token");
+
+ const config: any = {
+ rootUrl: "https://zzzzz.arvadosapi.com",
+ uuidPrefix: "zzzzz",
+ remoteHosts: { },
+ apiRevision: 12345678,
+ clusterConfig: {
+ Login: { LoginCluster: "" },
+ },
+ };
+
+ // Set up auth, confirm that no extra token was requested
+ await store.dispatch(initAuth(config))
+ expect(store.getState().auth.apiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).toBeUndefined();
+
+ // Ask for an extra token
+ await store.dispatch(getNewExtraToken());
+ expect(store.getState().auth.apiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).toBeDefined();
+ const extraToken = store.getState().auth.extraApiToken;
+
+ // Ask for a cached extra token
+ await store.dispatch(getNewExtraToken(true));
+ expect(store.getState().auth.extraApiToken).toBe(extraToken);
+
+ // Ask for a new extra token, should make a second api request
+ await store.dispatch(getNewExtraToken(false));
+ expect(store.getState().auth.extraApiToken).toBeDefined();
+ expect(store.getState().auth.extraApiToken).not.toBe(extraToken);
+ });
+
+ it('should initialise state with user and api token from local storage', (done) => {
axiosMock
+ .onGet("/users/current")
+ .reply(200, {
+ email: "test@test.com",
+ first_name: "John",
+ last_name: "Doe",
+ uuid: "zzzzz-tpzed-abcefg",
+ owner_uuid: "ownerUuid",
+ is_admin: false,
+ is_active: true,
+ username: "jdoe",
+ prefs: {}
+ })
+ .onGet("/api_client_authorizations/current")
+ .reply(200, {
+ expires_at: "2140-01-01T00:00:00.000Z"
+ })
.onGet("https://xc59z.arvadosapi.com/discovery/v1/apis/arvados/v1/rest")
.reply(200, {
baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
uuidPrefix: "zzzzz",
remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
apiRevision: 12345678,
+ clusterConfig: {
+ Login: { LoginCluster: "" },
+ },
};
store.dispatch(initAuth(config));
const auth = store.getState().auth;
if (auth.apiToken === "token" &&
auth.sessions.length === 2 &&
- auth.sessions[0].status === 2 &&
- auth.sessions[1].status === 2
+ auth.sessions[0].status === SessionStatus.VALIDATED &&
+ auth.sessions[1].status === SessionStatus.VALIDATED
) {
try {
expect(auth).toEqual({
apiToken: "token",
+ apiTokenExpiration: new Date("2140-01-01T00:00:00.000Z"),
config: {
apiRevision: 12345678,
+ clusterConfig: {
+ Login: {
+ LoginCluster: "",
+ },
+ },
remoteHosts: {
"xc59z": "xc59z.arvadosapi.com",
},
uuidPrefix: "zzzzz",
},
sshKeys: [],
+ extraApiToken: undefined,
+ extraApiTokenExpiration: undefined,
homeCluster: "zzzzz",
localCluster: "zzzzz",
loginCluster: undefined,
remoteHostsConfig: {
"zzzzz": {
"apiRevision": 12345678,
+ "clusterConfig": {
+ "Login": {
+ "LoginCluster": "",
+ },
+ },
"remoteHosts": {
"xc59z": "xc59z.arvadosapi.com",
},
});
done();
} catch (e) {
- console.log(e);
+ fail(e);
}
}
});
import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
import { addRemoteConfig } from './auth-action-session';
+import { getTokenV2 } from '~/models/api-client-authorization';
export const authActions = unionize({
LOGIN: {},
LOGOUT: ofType<{ deleteLinkData: boolean }>(),
SET_CONFIG: ofType<{ config: Config }>(),
- INIT_USER: ofType<{ user: User, token: string }>(),
+ SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+ RESET_EXTRA_TOKEN: {},
+ INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date }>(),
USER_DETAILS_REQUEST: {},
USER_DETAILS_SUCCESS: ofType<User>(),
SET_SSH_KEYS: ofType<SshKeyResource[]>(),
REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
});
-export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const initAuth = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
// Cancel any link account ops in progress unless the user has
// just logged in or there has been a successful link operation
const data = services.linkAccountService.getLinkOpStatus();
if (!matchTokenRoute(location.pathname) &&
(!matchFedTokenRoute(location.pathname)) && data === undefined) {
- dispatch<any>(cancelLinking()).then(() => {
- dispatch<any>(init(config));
- });
- } else {
- dispatch<any>(init(config));
+ await dispatch<any>(cancelLinking());
}
+ return dispatch<any>(init(config));
};
-const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+const init = (config: Config) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
const remoteHosts = () => getState().auth.remoteHosts;
const token = services.authService.getApiToken();
let homeCluster = services.authService.getHomeCluster();
if (token && token !== "undefined") {
dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
- dispatch<any>(saveApiToken(token)).then(() => {
+ try {
+ await dispatch<any>(saveApiToken(token)); // .then(() => {
+ await dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+ } catch (e) {
dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
- }).catch(() => {
- dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
- });
+ }
}
};
return state.remoteHostsConfig[state.localCluster];
};
-export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
const config = dispatch<any>(getConfig);
const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
setAuthorizationHeader(svc, token);
- return svc.authService.getUserDetails().then((user: User) => {
- dispatch(authActions.INIT_USER({ user, token }));
- }).catch(() => {
+ try {
+ const user = await svc.authService.getUserDetails();
+ const client = await svc.apiClientAuthorizationService.get('current');
+ const tokenExpiration = client.expiresAt ? new Date(client.expiresAt) : undefined;
+ dispatch(authActions.INIT_USER({ user, token, tokenExpiration }));
+ } catch (e) {
dispatch(authActions.LOGOUT({ deleteLinkData: false }));
- });
+ }
};
+export const getNewExtraToken = (reuseStored: boolean = false) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ const extraToken = getState().auth.extraApiToken;
+ if (reuseStored && extraToken !== undefined) {
+ const config = dispatch<any>(getConfig);
+ const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+ setAuthorizationHeader(svc, extraToken);
+ try {
+ // Check the extra token's validity before using it. Refresh its
+ // expiration date just in case it changed.
+ const client = await svc.apiClientAuthorizationService.get('current');
+ dispatch(authActions.SET_EXTRA_TOKEN({
+ extraApiToken: extraToken,
+ extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+ }));
+ return extraToken;
+ } catch (e) {
+ dispatch(authActions.RESET_EXTRA_TOKEN());
+ }
+ }
+ const user = getState().auth.user;
+ const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+ if (user === undefined) { return; }
+ if (loginCluster !== "" && getState().auth.homeCluster !== loginCluster) { return; }
+ try {
+ // Do not show errors on the create call, cluster security configuration may not
+ // allow token creation and there's no way to know that from workbench2 side in advance.
+ const client = await services.apiClientAuthorizationService.create(undefined, false);
+ const newExtraToken = getTokenV2(client);
+ dispatch(authActions.SET_EXTRA_TOKEN({
+ extraApiToken: newExtraToken,
+ extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+ }));
+ return newExtraToken;
+ } catch {
+ console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+ return;
+ }
+ };
+
export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
export interface AuthState {
user?: User;
apiToken?: string;
+ apiTokenExpiration?: Date;
+ extraApiToken?: string;
+ extraApiTokenExpiration?: Date;
sshKeys: SshKeyResource[];
sessions: Session[];
localCluster: string;
const initialState: AuthState = {
user: undefined,
apiToken: undefined,
+ apiTokenExpiration: undefined,
+ extraApiToken: undefined,
+ extraApiTokenExpiration: undefined,
sshKeys: [],
sessions: [],
localCluster: "",
export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
return authActions.match(action, {
- SET_CONFIG: ({ config }) => {
- return {
+ SET_CONFIG: ({ config }) =>
+ ({
...state,
config,
localCluster: config.uuidPrefix,
- remoteHosts: { ...config.remoteHosts, [config.uuidPrefix]: new URL(config.rootUrl).host },
+ remoteHosts: {
+ ...config.remoteHosts,
+ [config.uuidPrefix]: new URL(config.rootUrl).host
+ },
homeCluster: config.loginCluster || config.uuidPrefix,
loginCluster: config.loginCluster,
- remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config }
- };
- },
- REMOTE_CLUSTER_CONFIG: ({ config }) => {
- return {
+ remoteHostsConfig: {
+ ...state.remoteHostsConfig,
+ [config.uuidPrefix]: config
+ }
+ }),
+ REMOTE_CLUSTER_CONFIG: ({ config }) =>
+ ({
...state,
- remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
- };
- },
- INIT_USER: ({ user, token }) => {
- return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
- },
- LOGIN: () => {
- return state;
- },
- LOGOUT: () => {
- return { ...state, apiToken: undefined };
- },
- USER_DETAILS_SUCCESS: (user: User) => {
- return { ...state, user, homeCluster: user.uuid.substr(0, 5) };
- },
- SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
- return { ...state, sshKeys };
- },
- ADD_SSH_KEY: (sshKey: SshKeyResource) => {
- return { ...state, sshKeys: state.sshKeys.concat(sshKey) };
- },
- REMOVE_SSH_KEY: (uuid: string) => {
- return { ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) };
- },
- SET_HOME_CLUSTER: (homeCluster: string) => {
- return { ...state, homeCluster };
- },
- SET_SESSIONS: (sessions: Session[]) => {
- return { ...state, sessions };
- },
- ADD_SESSION: (session: Session) => {
- return { ...state, sessions: state.sessions.concat(session) };
- },
- REMOVE_SESSION: (clusterId: string) => {
- return {
+ remoteHostsConfig: {
+ ...state.remoteHostsConfig,
+ [config.uuidPrefix]: config
+ },
+ }),
+ SET_EXTRA_TOKEN: ({ extraApiToken, extraApiTokenExpiration }) =>
+ ({ ...state, extraApiToken, extraApiTokenExpiration }),
+ RESET_EXTRA_TOKEN: () =>
+ ({ ...state, extraApiToken: undefined, extraApiTokenExpiration: undefined }),
+ INIT_USER: ({ user, token, tokenExpiration }) =>
+ ({ ...state,
+ user,
+ apiToken: token,
+ apiTokenExpiration: tokenExpiration,
+ homeCluster: user.uuid.substr(0, 5)
+ }),
+ LOGIN: () => state,
+ LOGOUT: () => ({ ...state, apiToken: undefined }),
+ USER_DETAILS_SUCCESS: (user: User) =>
+ ({ ...state, user, homeCluster: user.uuid.substr(0, 5) }),
+ SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => ({ ...state, sshKeys }),
+ ADD_SSH_KEY: (sshKey: SshKeyResource) =>
+ ({ ...state, sshKeys: state.sshKeys.concat(sshKey) }),
+ REMOVE_SSH_KEY: (uuid: string) =>
+ ({ ...state, sshKeys: state.sshKeys.filter((sshKey) => sshKey.uuid !== uuid) }),
+ SET_HOME_CLUSTER: (homeCluster: string) => ({ ...state, homeCluster }),
+ SET_SESSIONS: (sessions: Session[]) => ({ ...state, sessions }),
+ ADD_SESSION: (session: Session) =>
+ ({ ...state, sessions: state.sessions.concat(session) }),
+ REMOVE_SESSION: (clusterId: string) =>
+ ({
...state,
sessions: state.sessions.filter(
session => session.clusterId !== clusterId
)
- };
- },
- UPDATE_SESSION: (session: Session) => {
- return {
+ }),
+ UPDATE_SESSION: (session: Session) =>
+ ({
...state,
sessions: state.sessions.map(
s => s.clusterId === session.clusterId ? session : s
)
- };
- },
+ }),
default: () => state
});
};
import { RootState } from "~/store/store";
import { ServiceRepository } from "~/services/services";
import { dialogActions } from '~/store/dialog/dialog-actions';
+import { getNewExtraToken } from "../auth/auth-action";
export const COLLECTION_WEBDAV_S3_DIALOG_NAME = 'collectionWebdavS3Dialog';
}
export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
- (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ await dispatch<any>(getNewExtraToken(true));
dispatch(dialogActions.OPEN_DIALOG({
id: COLLECTION_WEBDAV_S3_DIALOG_NAME,
data: {
title: 'Access Collection using WebDAV or S3',
- token: getState().auth.apiToken,
+ token: getState().auth.extraApiToken || getState().auth.apiToken,
downloadUrl: getState().auth.config.keepWebServiceUrl,
collectionsUrl: getState().auth.config.keepWebInlineServiceUrl,
localCluster: getState().auth.localCluster,
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({
}
};
-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:
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { getProperty } from '~/store/properties/properties';
-import { propertiesActions } from '~/store/properties/properties-actions';
-import { RootState } from '~/store/store';
-
-export const CURRENT_TOKEN_DIALOG_NAME = 'currentTokenDialog';
-const API_HOST_PROPERTY_NAME = 'apiHost';
-
-export interface CurrentTokenDialogData {
- currentToken: string;
- apiHost: string;
-}
-
-export const setCurrentTokenDialogApiHost = (apiHost: string) =>
- propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
-
-export const getCurrentTokenDialogData = (state: RootState): CurrentTokenDialogData => ({
- apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
- currentToken: state.auth.apiToken || '',
-});
-
-export const openCurrentTokenDialog = dialogActions.OPEN_DIALOG({ id: CURRENT_TOKEN_DIALOG_NAME, data: {} });
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
//
// 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
+};
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>()
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { getProperty } from '~/store/properties/properties';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { RootState } from '~/store/store';
+
+export const TOKEN_DIALOG_NAME = 'tokenDialog';
+const API_HOST_PROPERTY_NAME = 'apiHost';
+
+export interface TokenDialogData {
+ token: string;
+ tokenExpiration?: Date;
+ apiHost: string;
+ canCreateNewTokens: boolean;
+}
+
+export const setTokenDialogApiHost = (apiHost: string) =>
+ propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
+
+export const getTokenDialogData = (state: RootState): TokenDialogData => {
+ const loginCluster = state.auth.config.clusterConfig.Login.LoginCluster;
+ const canCreateNewTokens = !(loginCluster !== "" && state.auth.homeCluster !== loginCluster);
+
+ return {
+ apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
+ token: state.auth.extraApiToken || state.auth.apiToken || '',
+ tokenExpiration: state.auth.extraApiToken
+ ? state.auth.extraApiTokenExpiration
+ : state.auth.apiTokenExpiration,
+ canCreateNewTokens,
+ };
+};
+
+export const openTokenDialog = dialogActions.OPEN_DIALOG({ id: TOKEN_DIALOG_NAME, data: {} });
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,
import { getResource } from '~/store/resources/resources';
import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
import { authActions } from '~/store/auth/auth-action';
+import { getTokenV2 } from "~/models/api-client-authorization";
export const USERS_PANEL_ID = 'usersPanel';
export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
const data = getResource<UserResource>(uuid)(resources);
const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
if (data) {
- dispatch<any>(authActions.INIT_USER({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+ dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
location.reload();
dispatch<any>(navigateToRootProject);
}
import * as React from 'react';
import { configure, mount } from "enzyme";
import * as Adapter from 'enzyme-adapter-react-16';
-import { AutoLogoutComponent, AutoLogoutProps } from './auto-logout';
+import { AutoLogoutComponent, AutoLogoutProps, LAST_ACTIVE_TIMESTAMP } from './auto-logout';
configure({ adapter: new Adapter() });
let props: AutoLogoutProps;
const sessionIdleTimeout = 300;
const lastWarningDuration = 60;
+ const eventListeners = {};
jest.useFakeTimers();
+ beforeAll(() => {
+ window.addEventListener = jest.fn((event, cb) => {
+ eventListeners[event] = cb;
+ });
+ });
+
beforeEach(() => {
props = {
sessionIdleTimeout: sessionIdleTimeout,
jest.runTimersToTime(1*1000);
expect(props.doWarn).toBeCalled();
});
+
+ it('should reset the idle timer when activity event is received', () => {
+ jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+ expect(props.doWarn).not.toBeCalled();
+ // Simulate activity from other window/tab
+ eventListeners.storage({
+ key: LAST_ACTIVE_TIMESTAMP,
+ newValue: '42' // value currently doesn't matter
+ })
+ jest.runTimersToTime(1*1000);
+ // Warning should not appear because idle timer was reset
+ expect(props.doWarn).not.toBeCalled();
+ });
});
\ No newline at end of file
doCloseWarn: () => void;
}
-const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => {
- return {
- sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
- lastWarningDuration: ownProps.lastWarningDuration || 60,
- };
-};
+const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => ({
+ sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+ lastWarningDuration: ownProps.lastWarningDuration || 60,
+});
const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
doLogout: () => dispatch<any>(logout(true)),
export type AutoLogoutProps = AutoLogoutDataProps & AutoLogoutActionProps;
+const debounce = (delay: number | undefined, fn: Function) => {
+ let timerId: number | null;
+ return (...args: any[]) => {
+ if (timerId) { clearTimeout(timerId); }
+ timerId = setTimeout(() => {
+ fn(...args);
+ timerId = null;
+ }, delay);
+ };
+};
+
+export const LAST_ACTIVE_TIMESTAMP = 'lastActiveTimestamp';
+
export const AutoLogoutComponent = (props: AutoLogoutProps) => {
let logoutTimer: NodeJS.Timer;
- const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000 ;
+ const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000;
+
+ // Runs once after render
+ React.useEffect(() => {
+ window.addEventListener('storage', handleStorageEvents);
+ // Component cleanup
+ return () => {
+ window.removeEventListener('storage', handleStorageEvents);
+ };
+ }, []);
+
+ const handleStorageEvents = (e: StorageEvent) => {
+ if (e.key === LAST_ACTIVE_TIMESTAMP) {
+ // Other tab activity detected by a localStorage change event.
+ debounce(500, () => {
+ handleOnActive();
+ reset();
+ })();
+ }
+ };
const handleOnIdle = () => {
logoutTimer = setTimeout(
};
const handleOnActive = () => {
- clearTimeout(logoutTimer);
+ if (logoutTimer) { clearTimeout(logoutTimer); }
props.doCloseWarn();
};
- useIdleTimer({
+ const handleOnAction = () => {
+ // Notify the other tabs there was some activity.
+ const now = (new Date).getTime();
+ localStorage.setItem(LAST_ACTIVE_TIMESTAMP, now.toString());
+ };
+
+ const { reset } = useIdleTimer({
timeout: (props.lastWarningDuration < props.sessionIdleTimeout)
? 1000 * (props.sessionIdleTimeout - props.lastWarningDuration)
: 1,
onIdle: handleOnIdle,
onActive: handleOnActive,
+ onAction: handleOnAction,
debounce: 500
});
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",
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Button } from '@material-ui/core';
-import { mount, configure } from 'enzyme';
-import * as Adapter from 'enzyme-adapter-react-16';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { CurrentTokenDialogComponent } from './current-token-dialog';
-
-configure({ adapter: new Adapter() });
-
-jest.mock('toggle-selection', () => () => () => null);
-
-describe('<CurrentTokenDialog />', () => {
- let props;
- let wrapper;
-
- beforeEach(() => {
- props = {
- classes: {},
- data: {
- currentToken: '123123123123',
- },
- open: true,
- dispatch: jest.fn(),
- };
- });
-
- describe('copy to clipboard', () => {
- beforeEach(() => {
- wrapper = mount(<CurrentTokenDialogComponent {...props} />);
- });
-
- it('should copy API TOKEN to the clipboard', () => {
- // when
- wrapper.find(CopyToClipboard).find(Button).simulate('click');
-
- // and
- expect(props.dispatch).toHaveBeenCalledWith({
- payload: {
- hideDuration: 2000,
- kind: 1,
- message: 'Token copied to clipboard',
- },
- type: 'OPEN_SNACKBAR',
- });
- });
- });
-});
+++ /dev/null
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import * as React from 'react';
-import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography } from '@material-ui/core';
-import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { withDialog } from '~/store/dialog/with-dialog';
-import { WithDialogProps } from '~/store/dialog/with-dialog';
-import { connect, DispatchProp } from 'react-redux';
-import { CurrentTokenDialogData, getCurrentTokenDialogData, CURRENT_TOKEN_DIALOG_NAME } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
-import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
-
-type CssRules = 'link' | 'paper' | 'button' | 'copyButton';
-
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
- link: {
- color: theme.palette.primary.main,
- textDecoration: 'none',
- margin: '0px 4px'
- },
- paper: {
- padding: theme.spacing.unit,
- marginBottom: theme.spacing.unit * 2,
- backgroundColor: theme.palette.grey["200"],
- border: `1px solid ${theme.palette.grey["300"]}`
- },
- button: {
- fontSize: '0.8125rem',
- fontWeight: 600
- },
- copyButton: {
- boxShadow: 'none',
- marginTop: theme.spacing.unit * 2,
- marginBottom: theme.spacing.unit * 2,
- }
-});
-
-type CurrentTokenProps = CurrentTokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
-
-export class CurrentTokenDialogComponent extends React.Component<CurrentTokenProps> {
- onCopy = (message: string) => {
- this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
- message,
- hideDuration: 2000,
- kind: SnackbarKind.SUCCESS
- }));
- }
-
- getSnippet = ({ apiHost, currentToken }: CurrentTokenDialogData) =>
- `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
-export ARVADOS_API_TOKEN=${currentToken}
-export ARVADOS_API_HOST=${apiHost}
-unset ARVADOS_API_HOST_INSECURE`
-
- render() {
- const { classes, open, closeDialog, ...data } = this.props;
-
- return <Dialog
- open={open}
- onClose={closeDialog}
- fullWidth={true}
- maxWidth='md'>
- <DialogTitle>Current Token</DialogTitle>
- <DialogContent>
- <Typography paragraph={true}>
- The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
- <Typography component='span'>
- For more information see
- <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
- Getting an API token.
- </a>
- </Typography>
- </Typography>
- <Typography paragraph={true}>
- Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
- </Typography>
- <DefaultCodeSnippet lines={[this.getSnippet(data)]} />
- <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Token copied to clipboard')}>
- <Button
- color="primary"
- size="small"
- variant="contained"
- className={classes.copyButton}
- >
- COPY TO CLIPBOARD
- </Button>
- </CopyToClipboard>
- <Typography >
- Arvados
- <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
- do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
- </DialogActions>
- </Dialog>;
- }
-}
-
-export const CurrentTokenDialog =
- withStyles(styles)(
- connect(getCurrentTokenDialogData)(
- withDialog(CURRENT_TOKEN_DIALOG_NAME)(CurrentTokenDialogComponent)));
-
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 =>
import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
import { UserPanelIcon } from "~/components/icon/icon";
import { DispatchProp, connect } from 'react-redux';
-import { authActions } from '~/store/auth/auth-action';
+import { authActions, getNewExtraToken } from '~/store/auth/auth-action';
import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openTokenDialog } from '~/store/token-dialog/token-dialog-actions';
import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
import {
navigateToSiteManager,
{user.isActive ? <>
<MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
{!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
- <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</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>
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';
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;
}
}
}
}
)
-);
\ 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) {
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Button } from '@material-ui/core';
+import { mount, configure } from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { TokenDialogComponent } from './token-dialog';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('toggle-selection', () => () => () => null);
+
+describe('<CurrentTokenDialog />', () => {
+ let props;
+ let wrapper;
+
+ beforeEach(() => {
+ props = {
+ classes: {},
+ token: 'xxxtokenxxx',
+ apiHost: 'example.com',
+ open: true,
+ dispatch: jest.fn(),
+ };
+ });
+
+ describe('Get API Token dialog', () => {
+ beforeEach(() => {
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ });
+
+ it('should include API host and token', () => {
+ expect(wrapper.html()).toContain('export ARVADOS_API_HOST=example.com');
+ expect(wrapper.html()).toContain('export ARVADOS_API_TOKEN=xxxtokenxxx');
+ });
+
+ it('should show the token expiration if present', () => {
+ expect(props.tokenExpiration).toBeUndefined();
+ expect(wrapper.html()).toContain('This token does not have an expiration date');
+
+ const someDate = '2140-01-01T00:00:00.000Z'
+ props.tokenExpiration = new Date(someDate);
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ expect(wrapper.html()).toContain(props.tokenExpiration.toLocaleString());
+ });
+
+ it('should show a create new token button when allowed', () => {
+ expect(props.canCreateNewTokens).toBeFalsy();
+ expect(wrapper.html()).not.toContain('GET NEW TOKEN');
+
+ props.canCreateNewTokens = true;
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ expect(wrapper.html()).toContain('GET NEW TOKEN');
+ });
+ });
+
+ describe('copy to clipboard button', () => {
+ beforeEach(() => {
+ wrapper = mount(<TokenDialogComponent {...props} />);
+ });
+
+ it('should copy API TOKEN to the clipboard', () => {
+ // when
+ wrapper.find(CopyToClipboard).find(Button).simulate('click');
+
+ // and
+ expect(props.dispatch).toHaveBeenCalledWith({
+ payload: {
+ hideDuration: 2000,
+ kind: 1,
+ message: 'Shell code block copied',
+ },
+ type: 'OPEN_SNACKBAR',
+ });
+ });
+ });
+});
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+ Dialog,
+ DialogActions,
+ DialogTitle,
+ DialogContent,
+ WithStyles,
+ withStyles,
+ StyleRulesCallback,
+ Button,
+ Typography
+} from '@material-ui/core';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { withDialog } from '~/store/dialog/with-dialog';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { connect, DispatchProp } from 'react-redux';
+import {
+ TokenDialogData,
+ getTokenDialogData,
+ TOKEN_DIALOG_NAME,
+} from '~/store/token-dialog/token-dialog-actions';
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getNewExtraToken } from '~/store/auth/auth-action';
+import { DetailsAttributeComponent } from '~/components/details-attribute/details-attribute';
+
+type CssRules = 'link' | 'paper' | 'button' | 'actionButton' | 'codeBlock';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ link: {
+ color: theme.palette.primary.main,
+ textDecoration: 'none',
+ margin: '0px 4px'
+ },
+ paper: {
+ padding: theme.spacing.unit,
+ marginBottom: theme.spacing.unit * 2,
+ backgroundColor: theme.palette.grey["200"],
+ border: `1px solid ${theme.palette.grey["300"]}`
+ },
+ button: {
+ fontSize: '0.8125rem',
+ fontWeight: 600
+ },
+ actionButton: {
+ boxShadow: 'none',
+ marginTop: theme.spacing.unit * 2,
+ marginBottom: theme.spacing.unit * 2,
+ marginRight: theme.spacing.unit * 2,
+ },
+ codeBlock: {
+ fontSize: '0.8125rem',
+ },
+});
+
+type TokenDialogProps = TokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
+
+export class TokenDialogComponent extends React.Component<TokenDialogProps> {
+ onCopy = (message: string) => {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message,
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ }
+
+ onGetNewToken = async () => {
+ const newToken = await this.props.dispatch<any>(getNewExtraToken());
+ if (newToken) {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'New token retrieved',
+ hideDuration: 2000,
+ kind: SnackbarKind.SUCCESS
+ }));
+ } else {
+ this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+ message: 'Creating new tokens is not allowed',
+ hideDuration: 2000,
+ kind: SnackbarKind.WARNING
+ }));
+ }
+ }
+
+ getSnippet = ({ apiHost, token }: TokenDialogData) =>
+ `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
+export ARVADOS_API_TOKEN=${token}
+export ARVADOS_API_HOST=${apiHost}
+unset ARVADOS_API_HOST_INSECURE`
+
+ render() {
+ const { classes, open, closeDialog, ...data } = this.props;
+ const tokenExpiration = data.tokenExpiration
+ ? data.tokenExpiration.toLocaleString()
+ : `This token does not have an expiration date`;
+
+ return <Dialog
+ open={open}
+ onClose={closeDialog}
+ fullWidth={true}
+ maxWidth='md'>
+ <DialogTitle>Get API Token</DialogTitle>
+ <DialogContent>
+ <Typography paragraph={true}>
+ The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
+ <Typography component='span'>
+ For more information see
+ <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>
+ Getting an API token.
+ </a>
+ </Typography>
+ </Typography>
+
+ <DetailsAttributeComponent label='API Host' value={data.apiHost} copyValue={data.apiHost} onCopy={this.onCopy} />
+ <DetailsAttributeComponent label='API Token' value={data.token} copyValue={data.token} onCopy={this.onCopy} />
+ <DetailsAttributeComponent label='Token expiration' value={tokenExpiration} />
+ { this.props.canCreateNewTokens && <Button
+ onClick={() => this.onGetNewToken()}
+ color="primary"
+ size="small"
+ variant="contained"
+ className={classes.actionButton}
+ >
+ GET NEW TOKEN
+ </Button> }
+
+ <Typography paragraph={true}>
+ Paste the following lines at a shell prompt to set up the necessary environment for Arvados SDKs to authenticate to your account.
+ </Typography>
+ <DefaultCodeSnippet className={classes.codeBlock} lines={[this.getSnippet(data)]} />
+ <CopyToClipboard text={this.getSnippet(data)} onCopy={() => this.onCopy('Shell code block copied')}>
+ <Button
+ color="primary"
+ size="small"
+ variant="contained"
+ className={classes.actionButton}
+ >
+ COPY TO CLIPBOARD
+ </Button>
+ </CopyToClipboard>
+ <Typography>
+ Arvados
+ <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
+ do this for you automatically. This setup is needed only when you use the API remotely (e.g., from your own workstation).
+ </Typography>
+ </DialogContent>
+ <DialogActions>
+ <Button onClick={closeDialog} className={classes.button} color="primary">CLOSE</Button>
+ </DialogActions>
+ </Dialog>;
+ }
+}
+
+export const TokenDialog =
+ withStyles(styles)(
+ connect(getTokenDialogData)(
+ withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
+
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,
export interface FileInputProps {
input: FileCommandInputParameter;
+ options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
}
-export const FileInput = ({ input }: FileInputProps) =>
+export const FileInput = ({ input, options }: FileInputProps) =>
<Field
name={input.id}
commandInput={input}
component={FileInputComponent}
format={format}
parse={parse}
+ {...{
+ options
+ }}
validate={getValidation(input)} />;
const format = (value?: File) => value ? value.basename : '';
}
const FileInputComponent = connect()(
- class FileInputComponent extends React.Component<GenericInputProps & DispatchProp, FileInputComponentState> {
+ class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+ options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+ }, FileInputComponentState> {
state: FileInputComponentState = {
open: false,
};
pickerId={this.props.commandInput.id}
includeCollections
includeFiles
+ options={this.props.options}
toggleItemActive={this.setFile} />
</DialogContent>
<DialogActions>
return <StringInput input={input as StringCommandInputParameter} />;
case isPrimitiveOfType(input, CWLType.FILE):
- return <FileInput input={input as FileCommandInputParameter} />;
+ return <FileInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as FileCommandInputParameter} />;
case isPrimitiveOfType(input, CWLType.DIRECTORY):
return <DirectoryInput input={input as DirectoryCommandInputParameter} />;
import { ArvadosTheme } from '~/common/custom-theme';
import { ContextMenu } from "~/views-components/context-menu/context-menu";
import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog';
+import { TokenDialog } from '~/views-components/token-dialog/token-dialog';
import { RichTextEditorDialog } from '~/views-components/rich-text-editor-dialog/rich-text-editor-dialog';
import { Snackbar } from '~/views-components/snackbar/snackbar';
import { CollectionPanel } from '../collection-panel/collection-panel';
<CreateRepositoryDialog />
<CreateSshKeyDialog />
<CreateUserDialog />
- <CurrentTokenDialog />
+ <TokenDialog />
<FileRemoveDialog />
<FilesUploadCollectionDialog />
<GroupAttributesDialog />
set +e +o pipefail
kill ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID}
wait ${arvboot_PID} ${consume_stdout_PID} ${wb2_PID} ${consume_wb2_stdout_PID} || true
- if [ "${CLEANUP_ARVADOS_DIR}" -eq "1" ]; then
+ if [ ${CLEANUP_ARVADOS_DIR} -eq 1 ]; then
rm -rf ${ARVADOS_DIR}
fi
echo >&2 "done"
# Allow self-signed certs on 'wait-on'
export NODE_TLS_REJECT_UNAUTHORIZED=0
+ARVADOS_DIR="unset"
CLEANUP_ARVADOS_DIR=0
CYPRESS_MODE="run"
-ARVADOS_DIR=`mktemp -d`
WB2_DIR=`pwd`
while getopts "ia:w:" o; do
done
shift $((OPTIND-1))
+if [ "${ARVADOS_DIR}" = "unset" ]; then
+ echo "ARVADOS_DIR is unset, creating a temporary directory for new checkout"
+ ARVADOS_DIR=`mktemp -d`
+fi
+
+echo "ARVADOS_DIR is ${ARVADOS_DIR}"
+
ARVADOS_LOG=${ARVADOS_DIR}/arvados.log
ARVADOS_CONF=${WB2_DIR}/tools/arvados_config.yml