Merge branch '17595-Selecting-multiple-collections-as-inputs-to-a-workflow'
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Mon, 17 May 2021 16:59:51 +0000 (18:59 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Mon, 17 May 2021 16:59:56 +0000 (18:59 +0200)
closes #17595

Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

20 files changed:
cypress/integration/collection.spec.js
package.json
src/index.tsx
src/services/common-service/common-resource-service.test.ts
src/services/common-service/common-resource-service.ts
src/services/common-service/common-service.ts
src/services/project-service/project-service.test.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/context-menu/action-sets/collection-files-action-set.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx
src/views-components/context-menu/actions/collection-file-viewer-action.tsx
src/views-components/context-menu/actions/download-collection-file-action.tsx
src/views-components/context-menu/context-menu.tsx
src/views-components/token-dialog/token-dialog.tsx
tools/run-integration-tests.sh
yarn.lock

index 5bad87b3e7f4bee8256a0e515963a32f454bbcdd..c6d29b2c06c8183a651380dc20168e739b940826 100644 (file)
@@ -53,7 +53,7 @@ describe('Collection panel tests', function () {
                     const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find('dict')[0].children);
                     const map = {};
                     let i, j = 2;
-                    
+
                     for (i=0; i < childrenCollection.length; i += j) {
                       map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
                     }
@@ -116,6 +116,7 @@ describe('Collection panel tests', function () {
             // on this loop may pass an assertion from the first iteration by looking
             // for the same file name.
             const fileName = isWritable ? 'bar' : 'foo';
+            const subDirName = 'subdir';
             cy.createGroup(adminUser.token, {
                 name: 'Shared project',
                 group_class: 'project',
@@ -126,7 +127,7 @@ describe('Collection panel tests', function () {
                     name: 'Test collection',
                     owner_uuid: this.sharedGroup.uuid,
                     properties: { someKey: 'someValue' },
-                    manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`
+                    manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`
                 })
                     .as('testCollection').then(function () {
                         // Share the group with active user.
@@ -184,6 +185,7 @@ describe('Collection panel tests', function () {
                                     .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
                             }
                         });
+                        // Test context menus
                         cy.get('[data-cy=collection-files-panel]')
                             .contains(fileName).rightclick({ force: true });
                         cy.get('[data-cy=context-menu]')
@@ -193,6 +195,15 @@ describe('Collection panel tests', function () {
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
                         cy.get('body').click(); // Collapse the menu
+                        cy.get('[data-cy=collection-files-panel]')
+                            .contains(subDirName).rightclick({ force: true });
+                        cy.get('[data-cy=context-menu]')
+                            .should('not.contain', 'Download')
+                            .and('contain', 'Open in new tab')
+                            .and('contain', 'Copy to clipboard')
+                            .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
+                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
+                        cy.get('body').click(); // Collapse the menu
                         // Hamburger 'more options' menu button
                         cy.get('[data-cy=collection-files-panel-options-btn]')
                             .click()
@@ -202,16 +213,8 @@ describe('Collection panel tests', function () {
                         cy.get('[data-cy=collection-files-panel-options-btn]')
                             .click()
                         cy.get('[data-cy=context-menu]')
-                            // .should('contain', 'Download selected')
                             .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
                         cy.get('body').click(); // Collapse the menu
-                        // File item 'more options' button
-                        cy.get('[data-cy=file-item-options-btn')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Download')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
                     })
             })
         })
@@ -265,7 +268,9 @@ describe('Collection panel tests', function () {
                     cy.get('[data-cy=form-dialog]')
                         .should('contain', 'Rename')
                         .within(() => {
-                            cy.get('input').type(`{selectall}{backspace}${to}`);
+                            cy.get('input')
+                                .type('{selectall}{backspace}')
+                                .type(to, { parseSpecialCharSequences: false });
                         });
                     cy.get('[data-cy=form-submit-btn]').click();
                     cy.get('[data-cy=collection-files-panel]')
@@ -563,7 +568,8 @@ describe('Collection panel tests', function () {
         // Create new collection
         cy.get('[data-cy=side-panel-button]').click();
         cy.get('[data-cy=side-panel-new-collection]').click();
-        const collName = `Test collection (${Math.floor(999999 * Math.random())})`;
+        // Name between brackets tests bugfix #17582
+        const collName = `[Test collection (${Math.floor(999999 * Math.random())})]`;
         cy.get('[data-cy=form-dialog]')
             .should('contain', 'New collection')
             .within(() => {
index 735224471b3762d4b85fecc3d6f02f2c57bcd467..18e0be8f8565575f0c2af969c7dd14e85310f455 100644 (file)
@@ -38,6 +38,7 @@
     "lodash.mergewith": "4.6.2",
     "lodash.template": "4.5.0",
     "mem": "4.0.0",
+    "moment": "2.29.1",
     "parse-duration": "0.4.4",
     "prop-types": "15.7.2",
     "query-string": "6.9.0",
index e691c5d2433d8ee4f9ec40a0783a7b8943ede938..7803c6b7d77c34c56ef76c648dbfcb21a372817f 100644 (file)
@@ -25,7 +25,7 @@ import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from
 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 { collectionFilesItemActionSet, readOnlyCollectionFilesItemActionSet } from '~/views-components/context-menu/action-sets/collection-files-item-action-set';
+import { collectionDirectoryItemActionSet, collectionFileItemActionSet, readOnlyCollectionDirectoryItemActionSet, readOnlyCollectionFileItemActionSet } from '~/views-components/context-menu/action-sets/collection-files-item-action-set';
 import { collectionFilesNotSelectedActionSet } from '~/views-components/context-menu/action-sets/collection-files-not-selected-action-set';
 import { collectionActionSet, collectionAdminActionSet, oldCollectionVersionActionSet, readOnlyCollectionActionSet } from '~/views-components/context-menu/action-sets/collection-action-set';
 import { processActionSet } from '~/views-components/context-menu/action-sets/process-action-set';
@@ -74,8 +74,10 @@ addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet);
-addMenuActionSet(ContextMenuKind.COLLECTION_FILES_ITEM, collectionFilesItemActionSet);
-addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_ITEM, readOnlyCollectionFilesItemActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_DIRECTORY_ITEM, collectionDirectoryItemActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM, readOnlyCollectionDirectoryItemActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILE_ITEM, collectionFileItemActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILE_ITEM, readOnlyCollectionFileItemActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSet);
 addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet);
index d00412b8a69baa3941ec0bccca56f882928e0a6a..a7fdac621f18ea9a985a1191f308b7c83a9fbc50 100644 (file)
@@ -32,50 +32,50 @@ describe("CommonResourceService", () => {
 
     it("#create", async () => {
         axiosMock
-            .onPost("/resource")
+            .onPost("/resources")
             .reply(200, { owner_uuid: "ownerUuidValue" });
 
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         const resource = await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
         expect(resource).toEqual({ ownerUuid: "ownerUuidValue" });
     });
 
     it("#create maps request params to snake case", async () => {
         axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         await commonResourceService.create({ ownerUuid: "ownerUuidValue" });
-        expect(axiosInstance.post).toHaveBeenCalledWith("/resource", {owner_uuid: "ownerUuidValue"});
+        expect(axiosInstance.post).toHaveBeenCalledWith("/resources", {resource: {owner_uuid: "ownerUuidValue"}});
     });
 
     it("#create ignores fields listed as readonly", async () => {
         axiosInstance.post = jest.fn(() => Promise.resolve({data: {}}));
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         // UUID fields are read-only on all resources.
         await commonResourceService.create({ uuid: "this should be ignored", ownerUuid: "ownerUuidValue" });
-        expect(axiosInstance.post).toHaveBeenCalledWith("/resource", {owner_uuid: "ownerUuidValue"});
+        expect(axiosInstance.post).toHaveBeenCalledWith("/resources", {resource: {owner_uuid: "ownerUuidValue"}});
     });
 
     it("#update ignores fields listed as readonly", async () => {
         axiosInstance.put = jest.fn(() => Promise.resolve({data: {}}));
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         // UUID fields are read-only on all resources.
         await commonResourceService.update('resource-uuid', { uuid: "this should be ignored", ownerUuid: "ownerUuidValue" });
-        expect(axiosInstance.put).toHaveBeenCalledWith("/resource/resource-uuid", {owner_uuid: "ownerUuidValue"});
+        expect(axiosInstance.put).toHaveBeenCalledWith("/resources/resource-uuid", {resource:  {owner_uuid: "ownerUuidValue"}});
     });
 
     it("#delete", async () => {
         axiosMock
-            .onDelete("/resource/uuid")
+            .onDelete("/resources/uuid")
             .reply(200, { deleted_at: "now" });
 
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         const resource = await commonResourceService.delete("uuid");
         expect(resource).toEqual({ deletedAt: "now" });
     });
 
     it("#get", async () => {
         axiosMock
-            .onGet("/resource/uuid")
+            .onGet("/resources/uuid")
             .reply(200, {
                 modified_at: "now",
                 properties: {
@@ -83,7 +83,7 @@ describe("CommonResourceService", () => {
                 }
             });
 
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         const resource = await commonResourceService.get("uuid");
         // Only first level keys are mapped to camel case
         expect(resource).toEqual({
@@ -96,7 +96,7 @@ describe("CommonResourceService", () => {
 
     it("#list", async () => {
         axiosMock
-            .onGet("/resource")
+            .onGet("/resources")
             .reply(200, {
                 kind: "kind",
                 offset: 2,
@@ -110,7 +110,7 @@ describe("CommonResourceService", () => {
                 items_available: 20
             });
 
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         const resource = await commonResourceService.list({ limit: 10, offset: 1 });
         // First level keys are mapped to camel case inside "items" arrays
         expect(resource).toEqual({
@@ -129,10 +129,10 @@ describe("CommonResourceService", () => {
 
     it("#list using POST when query string is too big", async () => {
         axiosMock
-            .onAny("/resource")
+            .onAny("/resources")
             .reply(200);
         const tooBig = 'x'.repeat(1500);
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         await commonResourceService.list({ filters: tooBig });
         expect(axiosMock.history.get.length).toBe(0);
         expect(axiosMock.history.post.length).toBe(1);
@@ -142,10 +142,10 @@ describe("CommonResourceService", () => {
 
     it("#list using GET when query string is not too big", async () => {
         axiosMock
-            .onAny("/resource")
+            .onAny("/resources")
             .reply(200);
         const notTooBig = 'x'.repeat(1480);
-        const commonResourceService = new CommonResourceService(axiosInstance, "resource", actions);
+        const commonResourceService = new CommonResourceService(axiosInstance, "resources", actions);
         await commonResourceService.list({ filters: notTooBig });
         expect(axiosMock.history.post.length).toBe(0);
         expect(axiosMock.history.get.length).toBe(1);
index bc24f22796b21001bce09b358e1efe6c657d6248..83af1e13acdc6af6b9a4133e17fbcd1741df3e1d 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { AxiosInstance } from "axios";
+import * as _ from "lodash";
 import { Resource } from "src/models/resource";
 import { ApiActions } from "~/services/api/api-actions";
 import { CommonService } from "~/services/common-service/common-service";
@@ -26,17 +27,25 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
     }
 
     create(data?: Partial<T>) {
+        let payload: any;
         if (data !== undefined) {
             this.readOnlyFields.forEach( field => delete data[field] );
+            payload = {
+                [this.resourceType.slice(0, -1)]: CommonService.mapKeys(_.snakeCase)(data),
+            };
         }
-        return super.create(data);
+        return super.create(payload);
     }
 
     update(uuid: string, data: Partial<T>) {
+        let payload: any;
         if (data !== undefined) {
             this.readOnlyFields.forEach( field => delete data[field] );
+            payload = {
+                [this.resourceType.slice(0, -1)]: CommonService.mapKeys(_.snakeCase)(data),
+            };
         }
-        return super.update(uuid, data);
+        return super.update(uuid, payload);
     }
 }
 
index e43f9f8f136a7404af40b9ffb0626cffd650d9ad..34bcb4c3bb72708915d618ade9bff5ef7e724f63 100644 (file)
@@ -42,7 +42,7 @@ export class CommonService<T> {
 
     constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
         this.serverApi = serverApi;
-        this.resourceType = '/' + resourceType;
+        this.resourceType = resourceType;
         this.actions = actions;
         this.readOnlyFields = readOnlyFields;
     }
@@ -97,7 +97,7 @@ export class CommonService<T> {
     create(data?: Partial<T>, showErrors?: boolean) {
         return CommonService.defaultResponse(
             this.serverApi
-                .post<T>(this.resourceType, data && CommonService.mapKeys(_.snakeCase)(data)),
+                .post<T>(`/${this.resourceType}`, data && CommonService.mapKeys(_.snakeCase)(data)),
             this.actions,
             true, // mapKeys
             showErrors
@@ -108,7 +108,7 @@ export class CommonService<T> {
         this.validateUuid(uuid);
         return CommonService.defaultResponse(
             this.serverApi
-                .delete(this.resourceType + '/' + uuid),
+                .delete(`/${this.resourceType}/${uuid}`),
             this.actions
         );
     }
@@ -117,7 +117,7 @@ export class CommonService<T> {
         this.validateUuid(uuid);
         return CommonService.defaultResponse(
             this.serverApi
-                .get<T>(this.resourceType + '/' + uuid),
+                .get<T>(`/${this.resourceType}/${uuid}`),
             this.actions,
             true, // mapKeys
             showErrors
@@ -134,7 +134,7 @@ export class CommonService<T> {
 
         if (QueryString.stringify(params).length <= 1500) {
             return CommonService.defaultResponse(
-                this.serverApi.get(this.resourceType, { params }),
+                this.serverApi.get(`/${this.resourceType}`, { params }),
                 this.actions
             );
         } else {
@@ -147,7 +147,7 @@ export class CommonService<T> {
                 }
             });
             return CommonService.defaultResponse(
-                this.serverApi.post(this.resourceType, formData, {
+                this.serverApi.post(`/${this.resourceType}`, formData, {
                     params: {
                         _method: 'GET'
                     }
@@ -161,7 +161,7 @@ export class CommonService<T> {
         this.validateUuid(uuid);
         return CommonService.defaultResponse(
             this.serverApi
-                .put<T>(this.resourceType + '/' + uuid, data && CommonService.mapKeys(_.snakeCase)(data)),
+                .put<T>(`/${this.resourceType}/${uuid}`, data && CommonService.mapKeys(_.snakeCase)(data)),
             this.actions
         );
     }
index 3634b8cba60a3fc84621b4f12ef87c56ad9b53b6..71e8b6d086843bbaf983cd8f9f70791f1705ab45 100644 (file)
@@ -19,8 +19,10 @@ describe("CommonResourceService", () => {
         const projectService = new ProjectService(axiosInstance, actions);
         const resource = await projectService.create({ name: "nameValue" });
         expect(axiosInstance.post).toHaveBeenCalledWith("/groups", {
-            name: "nameValue",
-            group_class: "project"
+            group: {
+                name: "nameValue",
+                group_class: "project"
+            }
         });
     });
 
index a02f922df435d9f0b1c9065ef9a9e9f4a317ebd6..d588ce1f387b61e0dc04b4f5c3758315c23d6db1 100644 (file)
@@ -56,7 +56,7 @@ const getClusterConfig = async (origin: string, apiClient: AxiosInstance): Promi
     return null;
 };
 
-const getRemoteHostConfig = async (remoteHost: string, useApiClient?: AxiosInstance): Promise<Config | null> => {
+export const getRemoteHostConfig = async (remoteHost: string, useApiClient?: AxiosInstance): Promise<Config | null> => {
     const apiClient = useApiClient || Axios.create({ headers: {} });
 
     let url = remoteHost;
index abc2a5a1a438f2237b88e166a884f957ce74dfee..145d5d06d43e4a8a2b5242162bb2c4ad6795d660 100644 (file)
@@ -9,18 +9,20 @@ import 'jest-localstorage-mock';
 import { ServiceRepository, createServices } from "~/services/services";
 import { configureStore, RootStore } from "../store";
 import { createBrowserHistory } from "history";
-import { Config, mockConfig } from '~/common/config';
+import { 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 Axios, { AxiosInstance } from "axios";
 import MockAdapter from "axios-mock-adapter";
 import { ImportMock } from 'ts-mock-imports';
 import * as servicesModule from "~/services/services";
+import * as authActionSessionModule from "./auth-action-session";
 import { SessionStatus } from "~/models/session";
+import { getRemoteHostConfig } from "./auth-action-session";
 
 describe('auth-actions', () => {
-    const axiosInst = Axios.create({ headers: {} });
-    const axiosMock = new MockAdapter(axiosInst);
+    let axiosInst: AxiosInstance;
+    let axiosMock: MockAdapter;
 
     let store: RootStore;
     let services: ServiceRepository;
@@ -32,7 +34,8 @@ describe('auth-actions', () => {
     let importMocks: any[];
 
     beforeEach(() => {
-        axiosMock.reset();
+        axiosInst = Axios.create({ headers: {} });
+        axiosMock = new MockAdapter(axiosInst);
         services = createServices(mockConfig({}), actions, axiosInst);
         store = configureStore(createBrowserHistory(), services, config);
         localStorage.clear();
@@ -78,7 +81,7 @@ describe('auth-actions', () => {
         localStorage.setItem(API_TOKEN_KEY, "token");
 
         const config: any = {
-            rootUrl: "https://zzzzz.arvadosapi.com",
+            rootUrl: "https://zzzzz.example.com",
             uuidPrefix: "zzzzz",
             remoteHosts: { },
             apiRevision: 12345678,
@@ -108,6 +111,76 @@ describe('auth-actions', () => {
         expect(store.getState().auth.extraApiToken).not.toBe(extraToken);
     });
 
+    it('requests remote token data to login cluster', async () => {
+        const localClusterTokenExpiration = "2020-01-01T00:00:00.000Z";
+        const loginClusterTokenExpiration = "2140-01-01T00:00:00.000Z";
+        axiosMock
+            .onGet("/users/current")
+            .reply(200, {
+                email: "test@test.com",
+                first_name: "John",
+                last_name: "Doe",
+                uuid: "zzzz1-tpzed-abcefg",
+                owner_uuid: "ownerUuid",
+                is_admin: false,
+                is_active: true,
+                username: "jdoe",
+                prefs: {}
+            })
+            .onGet("https://zzzz1.example.com/discovery/v1/apis/arvados/v1/rest")
+            .reply(200, {
+                baseUrl: "https://zzzz1.example.com/arvados/v1",
+                keepWebServiceUrl: "",
+                keepWebInlineServiceUrl: "",
+                remoteHosts: {},
+                rootUrl: "https://zzzz1.example.com",
+                uuidPrefix: "zzzz1",
+                websocketUrl: "",
+                workbenchUrl: "",
+                workbench2Url: "",
+                revision: 12345678
+            })
+            // Local cluster -- cached token
+            .onGet("https://zzzzz.example.com/arvados/v1/api_client_authorizations/current")
+            .reply(200, {
+                uuid: 'zzzz1-gj3su-aaaaaaa',
+                expires_at: localClusterTokenExpiration,
+                api_token: 'tokensecret',
+            })
+            // Login cluster -- authoritative token copy
+            .onGet("https://zzzz1.example.com/arvados/v1/api_client_authorizations/current")
+            .reply(200, {
+                uuid: 'zzzz1-gj3su-aaaaaaa',
+                expires_at: loginClusterTokenExpiration,
+                api_token: 'tokensecret',
+            });
+
+        const config: any = {
+            rootUrl: "https://zzzzz.example.com",
+            uuidPrefix: "zzzzz",
+            remoteHosts: { zzzz1: "zzzz1.example.com" },
+            apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "zzzz1" },
+            },
+        };
+
+        const remoteHostConfig = await getRemoteHostConfig(config.remoteHosts.zzzz1, axiosInst);
+        expect(remoteHostConfig).not.toBeFalsy;
+        services = createServices(remoteHostConfig!, actions, axiosInst);
+
+        importMocks.push(ImportMock.mockFunction(authActionSessionModule, 'getRemoteHostConfig', remoteHostConfig));
+        importMocks.push(ImportMock.mockFunction(servicesModule, 'createServices', services));
+
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
+        localStorage.setItem(API_TOKEN_KEY, "v2/zzzz1-gj3su-aaaaaaa/tokensecret");
+
+        await store.dispatch(initAuth(config));
+        expect(store.getState().auth.apiToken).toBeDefined();
+        expect(localClusterTokenExpiration).not.toBe(loginClusterTokenExpiration);
+        expect(store.getState().auth.apiTokenExpiration).toEqual(new Date(loginClusterTokenExpiration));
+    });
+
     it('should initialise state with user and api token from local storage', (done) => {
         axiosMock
             .onGet("/users/current")
@@ -126,13 +199,13 @@ describe('auth-actions', () => {
             .reply(200, {
                 expires_at: "2140-01-01T00:00:00.000Z"
             })
-            .onGet("https://xc59z.arvadosapi.com/discovery/v1/apis/arvados/v1/rest")
+            .onGet("https://xc59z.example.com/discovery/v1/apis/arvados/v1/rest")
             .reply(200, {
-                baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
+                baseUrl: "https://xc59z.example.com/arvados/v1",
                 keepWebServiceUrl: "",
                 keepWebInlineServiceUrl: "",
                 remoteHosts: {},
-                rootUrl: "https://xc59z.arvadosapi.com",
+                rootUrl: "https://xc59z.example.com",
                 uuidPrefix: "xc59z",
                 websocketUrl: "",
                 workbenchUrl: "",
@@ -147,9 +220,9 @@ describe('auth-actions', () => {
         localStorage.setItem(API_TOKEN_KEY, "token");
 
         const config: any = {
-            rootUrl: "https://zzzzz.arvadosapi.com",
+            rootUrl: "https://zzzzz.example.com",
             uuidPrefix: "zzzzz",
-            remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
+            remoteHosts: { xc59z: "xc59z.example.com" },
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "" },
@@ -177,9 +250,9 @@ describe('auth-actions', () => {
                                 },
                             },
                             remoteHosts: {
-                                "xc59z": "xc59z.arvadosapi.com",
+                                "xc59z": "xc59z.example.com",
                             },
-                            rootUrl: "https://zzzzz.arvadosapi.com",
+                            rootUrl: "https://zzzzz.example.com",
                             uuidPrefix: "zzzzz",
                         },
                         sshKeys: [],
@@ -197,21 +270,21 @@ describe('auth-actions', () => {
                                     },
                                 },
                                 "remoteHosts": {
-                                    "xc59z": "xc59z.arvadosapi.com",
+                                    "xc59z": "xc59z.example.com",
                                 },
-                                "rootUrl": "https://zzzzz.arvadosapi.com",
+                                "rootUrl": "https://zzzzz.example.com",
                                 "uuidPrefix": "zzzzz",
                             },
                             "xc59z": mockConfig({
                                 apiRevision: 12345678,
-                                baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
-                                rootUrl: "https://xc59z.arvadosapi.com",
+                                baseUrl: "https://xc59z.example.com/arvados/v1",
+                                rootUrl: "https://xc59z.example.com",
                                 uuidPrefix: "xc59z"
                             })
                         },
                         remoteHosts: {
-                            zzzzz: "zzzzz.arvadosapi.com",
-                            xc59z: "xc59z.arvadosapi.com"
+                            zzzzz: "zzzzz.example.com",
+                            xc59z: "xc59z.example.com"
                         },
                         sessions: [{
                             "active": true,
@@ -219,7 +292,7 @@ describe('auth-actions', () => {
                             "clusterId": "zzzzz",
                             "email": "test@test.com",
                             "loggedIn": true,
-                            "remoteHost": "https://zzzzz.arvadosapi.com",
+                            "remoteHost": "https://zzzzz.example.com",
                             "status": 2,
                             "token": "token",
                             "name": "John Doe",
@@ -232,7 +305,7 @@ describe('auth-actions', () => {
                             "clusterId": "xc59z",
                             "email": "",
                             "loggedIn": false,
-                            "remoteHost": "xc59z.arvadosapi.com",
+                            "remoteHost": "xc59z.example.com",
                             "status": 2,
                             "token": "",
                             "name": "",
index 8c44aec448754ad9115a25c0730a911664a9b23a..4fdb29a1c9b3217833176b70b64ec665013d05b0 100644 (file)
@@ -15,7 +15,7 @@ import { createServices, setAuthorizationHeader } from "~/services/services";
 import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
 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 { addRemoteConfig, getRemoteHostConfig } from './auth-action-session';
 import { getTokenV2 } from '~/models/api-client-authorization';
 
 export const authActions = unionize({
@@ -68,9 +68,8 @@ const init = (config: Config) => async (dispatch: Dispatch, getState: () => Root
     if (token && token !== "undefined") {
         dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
         try {
-            await dispatch<any>(saveApiToken(token)); // .then(() => {
-            await dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
-        } catch (e) {
+            await dispatch<any>(saveApiToken(token));
+        } finally {
             dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
         }
     }
@@ -82,7 +81,16 @@ export const getConfig = (dispatch: Dispatch, getState: () => RootState, service
 };
 
 export const saveApiToken = (token: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-    const config = dispatch<any>(getConfig);
+    let config: any;
+    const tokenParts = token.split('/');
+    const auth = getState().auth;
+    config = dispatch<any>(getConfig);
+
+    // If federated token, get user & token data from the token issuing cluster
+    if (tokenParts.length === 3 && tokenParts[1].substring(0, 5) !== auth.localCluster) {
+        config = await getRemoteHostConfig(auth.remoteHosts[tokenParts[1].substring(0, 5)]);
+    }
+
     const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
     setAuthorizationHeader(svc, token);
     try {
index 91420edb52e19932b7e5ea29756f1bdb5a3fb504..ddd59ea6ac282ce897947eeced45637ff68b09bb 100644 (file)
@@ -55,12 +55,17 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps,
         dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
     },
     onItemMenuOpen: (event, item, isWritable) => {
+        const isDirectory = item.data.type === 'directory';
         dispatch<any>(openContextMenu(
             event,
             {
                 menuKind: isWritable
-                    ? ContextMenuKind.COLLECTION_FILES_ITEM
-                    : ContextMenuKind.READONLY_COLLECTION_FILES_ITEM,
+                    ? isDirectory
+                        ? ContextMenuKind.COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.COLLECTION_FILE_ITEM
+                    : isDirectory
+                        ? ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
                 kind: ResourceKind.COLLECTION,
                 name: item.data.name,
                 uuid: item.id,
index 7e885615d07c453c8cb39190d9b1e664cc1f787a..03cfdb9bb8f806e52fa42394f38fc23bb2b08dcc 100644 (file)
@@ -5,8 +5,8 @@
 import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
 import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
 import { openCollectionPartialCopyDialog, openCollectionPartialCopyToSelectedCollectionDialog } from '~/store/collections/collection-partial-copy-actions';
-// import { DownloadCollectionFileAction } from "~/views-components/context-menu/actions/download-collection-file-action";
 
+// These action sets are used on the multi-select actions button.
 export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [[
     {
         name: "Select all",
@@ -20,10 +20,6 @@ export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [[
             dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
         }
     },
-    // { // Disabled for now as we need to create backend version of this feature which will be less buggy
-    //     component: DownloadCollectionFileAction,
-    //     execute: () => { return; }
-    // },
     {
         name: "Create a new collection with selected",
         execute: dispatch => {
index bfbdec610e23eeab39fa094b7a198666573036d1..c8f70b0f73103f41004c6ae11bb2b4320e0d9519 100644 (file)
@@ -9,11 +9,7 @@ import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-p
 import { CollectionFileViewerAction } from '~/views-components/context-menu/actions/collection-file-viewer-action';
 import { CollectionCopyToClipboardAction } from "../actions/collection-copy-to-clipboard-action";
 
-export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
-    {
-        component: DownloadCollectionFileAction,
-        execute: () => { return; }
-    },
+export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [[
     {
         component: CollectionFileViewerAction,
         execute: () => { return; },
@@ -24,7 +20,15 @@ export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
     }
 ]];
 
-export const collectionFilesItemActionSet: ContextMenuActionSet = readOnlyCollectionFilesItemActionSet.concat([[
+export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [[
+    {
+        component: DownloadCollectionFileAction,
+        execute: () => { return; }
+    },
+    ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
+]];
+
+const writableActionSet: ContextMenuActionSet = [[
     {
         name: "Rename",
         icon: RenameIcon,
@@ -42,4 +46,8 @@ export const collectionFilesItemActionSet: ContextMenuActionSet = readOnlyCollec
             dispatch<any>(openFileRemoveDialog(resource.uuid));
         }
     }
-]]);
\ No newline at end of file
+]];
+
+export const collectionDirectoryItemActionSet: ContextMenuActionSet = readOnlyCollectionDirectoryItemActionSet.concat(writableActionSet);
+
+export const collectionFileItemActionSet: ContextMenuActionSet = readOnlyCollectionFileItemActionSet.concat(writableActionSet);
\ No newline at end of file
index 4fc11fb25902388b48950e5a9e8138372967b6b9..e8518d1f78054b4fa0d8423a04f040a7c22a39d7 100644 (file)
@@ -12,9 +12,11 @@ const mapStateToProps = (state: RootState) => {
     const { resource } = state.contextMenu;
     const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
     const { keepWebServiceUrl } = state.auth.config;
-    if (resource && (
-        resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM ||
-        resource.menuKind === ContextMenuKind.READONLY_COLLECTION_FILES_ITEM)) {
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
         const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
         if (file) {
             return {
index aba355346bdbdf5fa9398a08d3faa53cd963818c..7d25f1cfc18a00d0f380cf4e2480cb18f34fd7c8 100644 (file)
@@ -12,9 +12,11 @@ import { getInlineFileUrl, sanitizeToken } from "./helpers";
 const mapStateToProps = (state: RootState) => {
     const { resource } = state.contextMenu;
     const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
-    if (resource && (
-        resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM ||
-        resource.menuKind === ContextMenuKind.READONLY_COLLECTION_FILES_ITEM)) {
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
         const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
         if (file) {
             const fileUrl = sanitizeToken(getInlineFileUrl(
index e1986d3cd338454551cc46b9563405d097257ab2..3af98c4eaad49ad6fbaa58a7817f34ee725a73ea 100644 (file)
@@ -13,9 +13,11 @@ import { sanitizeToken } from "./helpers";
 const mapStateToProps = (state: RootState) => {
     const { resource } = state.contextMenu;
     const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
-    if (resource && (
-        resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM ||
-        resource.menuKind === ContextMenuKind.READONLY_COLLECTION_FILES_ITEM)) {
+    if (resource && [
+        ContextMenuKind.COLLECTION_FILE_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+        ContextMenuKind.COLLECTION_DIRECTORY_ITEM,
+        ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM ].indexOf(resource.menuKind as ContextMenuKind) > -1) {
         const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
         if (file) {
             return {
index ee87d71a37d84da65a8f0594cf1cfe3130a97fc3..26e38f1996cd701dd15d6edde2512984b86aa663 100644 (file)
@@ -76,8 +76,10 @@ export enum ContextMenuKind {
     TRASH = "Trash",
     COLLECTION_FILES = "CollectionFiles",
     READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles",
-    COLLECTION_FILES_ITEM = "CollectionFilesItem",
-    READONLY_COLLECTION_FILES_ITEM = "ReadOnlyCollectionFilesItem",
+    COLLECTION_FILE_ITEM = "CollectionFileItem",
+    COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem",
+    READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem",
+    READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem",
     COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected",
     COLLECTION = 'Collection',
     COLLECTION_ADMIN = 'CollectionAdmin',
index 1aa3dd0790d393ace90b7149803ead89b6c901d8..fc01631d87d79d803264580f1b0f6ed3b04314ad 100644 (file)
@@ -28,6 +28,7 @@ import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-co
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { getNewExtraToken } from '~/store/auth/auth-action';
 import { DetailsAttributeComponent } from '~/components/details-attribute/details-attribute';
+import * as moment from 'moment';
 
 type CssRules = 'link' | 'paper' | 'button' | 'actionButton' | 'codeBlock';
 
@@ -95,7 +96,7 @@ unset ARVADOS_API_HOST_INSECURE`
     render() {
         const { classes, open, closeDialog, ...data } = this.props;
         const tokenExpiration = data.tokenExpiration
-            ? data.tokenExpiration.toLocaleString()
+            ? `${data.tokenExpiration.toLocaleString()} (${moment(data.tokenExpiration).fromNow()})`
             : `This token does not have an expiration date`;
 
         return <Dialog
index fbc456bd5e0bdf567d1a1f3de66488a8f139aa91..159bfc1cb3abf5439adc94a3141e0738718ca3e2 100755 (executable)
@@ -107,6 +107,7 @@ echo "Launching arvados in test mode..."
 coproc arvboot (~/go/bin/arvados-server boot \
     -type test \
     -config ${ARVADOS_CONF} \
+    -no-workbench1 \
     -own-temporary-database \
     -timeout 20m 2> ${ARVADOS_LOG})
 trap cleanup ERR EXIT
index 465d82aa135f2d05fdeae335da66e5c27a5723f9..01bb75381bffdcbde19da1b62ffc346ff9a27663 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -7425,7 +7425,7 @@ mkdirp@^0.5.4:
   dependencies:
     minimist "^1.2.5"
 
-moment@^2.27.0:
+moment@2.29.1, moment@^2.27.0:
   version "2.29.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
   integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==