13493: Merge branch 'master' into 13494-collection-version-browser
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 10 Nov 2020 17:37:59 +0000 (14:37 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 10 Nov 2020 17:37:59 +0000 (14:37 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas@di-pentima.com.ar>

29 files changed:
cypress/integration/collection-panel.spec.js
cypress/integration/favorites.js
cypress/integration/login.spec.js
src/common/config.ts
src/common/formatters.ts
src/common/redirect-to.test.ts [new file with mode: 0644]
src/common/redirect-to.ts [new file with mode: 0644]
src/components/details-attribute/details-attribute.tsx
src/components/icon/icon.tsx
src/index.tsx
src/services/collection-service/collection-service.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-middleware.test.ts
src/store/open-in-new-tab/open-in-new-tab.actions.ts [new file with mode: 0644]
src/store/store.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 [new file with mode: 0644]
src/views-components/context-menu/actions/collection-file-viewer-action.tsx
src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/copy-to-clipboard-action.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/download-action.tsx
src/views-components/context-menu/actions/download-collection-file-action.tsx
src/views-components/context-menu/actions/file-viewer-action.test.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/file-viewer-action.tsx
src/views-components/context-menu/actions/helpers.test.ts [new file with mode: 0644]
src/views-components/context-menu/actions/helpers.ts [new file with mode: 0644]
src/views-components/details-panel/collection-details.tsx
src/views/collection-panel/collection-panel.tsx

index 414d7e3ed40d12e91ba8155e888e4eab700a74ed..404d1c5b04f7ec2b2a17729908b0e6f0130dc60e 100644 (file)
@@ -64,8 +64,8 @@ describe('Collection panel tests', function() {
                         .click()
                     cy.get('[data-cy=context-menu]')
                         .should('contain', 'Add to favorites')
-                        .and(`${isWritable ? '' : 'not.'}contain`, 'Edit collection')
-                        .type('{esc}'); // Collapse the options menu
+                        .and(`${isWritable ? '' : 'not.'}contain`, 'Edit collection');
+                    cy.get('body').click(); // Collapse the menu avoiding details panel expansion
                     cy.get('[data-cy=collection-properties-panel]')
                         .should('contain', 'someKey')
                         .and('contain', 'someValue')
@@ -95,6 +95,15 @@ describe('Collection panel tests', function() {
                         cy.get('[data-cy=upload-button]')
                             .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
                     });
+                    cy.get('[data-cy=collection-files-panel]')
+                        .contains('bar').rightclick();
+                    cy.get('[data-cy=context-menu]')
+                        .should('contain', 'Download')
+                        .and('contain', 'Open in new tab')
+                        .and('contain', 'Copy to clipboard')
+                        .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
+                        .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
+                    cy.get('body').click(); // Collapse the menu
                     // Hamburger 'more options' menu button
                     cy.get('[data-cy=collection-files-panel-options-btn]')
                         .click()
@@ -106,14 +115,14 @@ describe('Collection panel tests', function() {
                     cy.get('[data-cy=context-menu]')
                         // .should('contain', 'Download selected')
                         .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
-                        .type('{esc}'); // Collapse the options menu
+                    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')
-                        .type('{esc}'); // Collapse
+                        .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
+                    cy.get('body').click(); // Collapse the menu
                 })
             })
         })
index b38399be506b65aba126c700183401601a73b151..0855c94e4ddc1dd12d5eb466a3ce01dc76dd2e92 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Collection panel tests', function() {
+describe('Favorites tests', function() {
     let activeUser;
     let adminUser;
 
index d88c7a6cfa7252a0825de0ba357a87c418947c14..25c8cd4b8a4179cf9f60ad2e7905e8204fac4788 100644 (file)
@@ -93,7 +93,7 @@ describe('Login tests', function() {
             })
         }, null, activeUser.token, true);
         // Should log the user out.
-        cy.get('[data-cy=breadcrumb-first]').click();
+        cy.visit('/');
         cy.get('div#root').should('contain', 'Please log in');
     })
 
index afbeb5aecce884a1ba98a9c505ce7d2fdd299c13..146ca90acf62de05f7c4a0214f74701eb6c97f0f 100644 (file)
@@ -89,6 +89,7 @@ export interface ClusterConfigJSON {
 export class Config {
     baseUrl: string;
     keepWebServiceUrl: string;
+    keepWebInlineServiceUrl: string;
     remoteHosts: {
         [key: string]: string
     };
@@ -114,6 +115,7 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => {
     config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL;
     config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL;
     config.keepWebServiceUrl = clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
+    config.keepWebInlineServiceUrl = clusterConfigJSON.Services.WebDAV.ExternalURL;
     config.loginCluster = clusterConfigJSON.Login.LoginCluster;
     config.clusterConfig = clusterConfigJSON;
     config.apiRevision = 0;
@@ -249,6 +251,7 @@ export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): Clust
 export const mockConfig = (config: Partial<Config>): Config => ({
     baseUrl: "",
     keepWebServiceUrl: "",
+    keepWebInlineServiceUrl: "",
     remoteHosts: {},
     rootUrl: "",
     uuidPrefix: "",
index 1386338c900197a2aa2d918f11a02195d6e88729..55fb050738af1d670984b9c6b38cc3f8bafb7ddc 100644 (file)
@@ -28,7 +28,7 @@ export const formatFileSize = (size?: number) => {
             }
         }
     }
-    return "";
+    return "0 B";
 };
 
 export const formatTime = (time: number, seconds?: boolean) => {
diff --git a/src/common/redirect-to.test.ts b/src/common/redirect-to.test.ts
new file mode 100644 (file)
index 0000000..e25d7be
--- /dev/null
@@ -0,0 +1,82 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { storeRedirects, handleRedirects } from './redirect-to';
+
+describe('redirect-to', () => {
+    const { location } = window;
+    const config: any = {
+        keepWebServiceUrl: 'http://localhost',
+        keepWebServiceInlineUrl: 'http://localhost'
+    };
+    const redirectTo = '/test123';
+    const locationTemplate = {
+        hash: '',
+        hostname: '',
+        origin: '',
+        host: '',
+        pathname: '',
+        port: '80',
+        protocol: 'http',
+        search: '',
+        reload: () => { },
+        replace: () => { },
+        assign: () => { },
+        ancestorOrigins: [],
+        href: '',
+    };
+
+    afterAll((): void => {
+        window.location = location;
+    });
+
+    describe('storeRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectTo=${redirectTo}`,
+            } as any;
+            Object.defineProperty(window, 'localStorage', {
+                value: {
+                    setItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should store redirectTo in the session storage', () => {
+            // when
+            storeRedirects();
+
+            // then
+            expect(window.localStorage.setItem).toHaveBeenCalledWith('redirectTo', redirectTo);
+        });
+    });
+
+    describe('handleRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectTo=${redirectTo}`,
+            } as any;;
+            Object.defineProperty(window, 'localStorage', {
+                value: {
+                    getItem: () => redirectTo,
+                    removeItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should redirect to page when it is present in session storage', () => {
+            // when
+            handleRedirects("abcxyz", config);
+
+            // then
+            expect(window.location.href).toBe(`${config.keepWebServiceUrl}${redirectTo}?api_token=abcxyz`);
+        });
+    });
+});
diff --git a/src/common/redirect-to.ts b/src/common/redirect-to.ts
new file mode 100644 (file)
index 0000000..f5ece21
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Config } from './config';
+
+const REDIRECT_TO_KEY = 'redirectTo';
+
+export const storeRedirects = () => {
+    if (window.location.href.indexOf(REDIRECT_TO_KEY) > -1) {
+        const { location: { href }, localStorage } = window;
+        const redirectUrl = href.split(`${REDIRECT_TO_KEY}=`)[1];
+
+        if (localStorage) {
+            localStorage.setItem(REDIRECT_TO_KEY, redirectUrl);
+        }
+    }
+};
+
+export const handleRedirects = (token: string, config: Config) => {
+    const { localStorage } = window;
+    const { keepWebServiceUrl } = config;
+
+    if (localStorage && localStorage.getItem(REDIRECT_TO_KEY)) {
+        const redirectUrl = localStorage.getItem(REDIRECT_TO_KEY);
+        localStorage.removeItem(REDIRECT_TO_KEY);
+        if (redirectUrl) {
+            const sep = redirectUrl.indexOf("?") > -1 ? "&" : "?";
+            window.location.href = `${keepWebServiceUrl}${redirectUrl}${sep}api_token=${token}`;
+        }
+    }
+};
index 18d0d8b797eb8dc6ee168089161712903934bbd0..4b8ee8378fa32c8fc3db839629780b2ebc133545 100644 (file)
@@ -20,24 +20,21 @@ type CssRules = 'attribute' | 'label' | 'value' | 'lowercaseValue' | 'link' | 'c
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     attribute: {
-        display: 'flex',
-        alignItems: 'flex-start'
+        marginBottom: ".6 rem"
     },
     label: {
         boxSizing: 'border-box',
         color: theme.palette.grey["500"],
-        width: '40%'
+        width: '100%'
     },
     value: {
         boxSizing: 'border-box',
-        width: '60%',
         alignItems: 'flex-start'
     },
     lowercaseValue: {
         textTransform: 'lowercase'
     },
     link: {
-        width: '60%',
         color: theme.palette.primary.main,
         textDecoration: 'none',
         overflowWrap: 'break-word',
@@ -45,9 +42,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     copyIcon: {
         marginLeft: theme.spacing.unit,
-        fontSize: '1.125rem',
         color: theme.palette.grey["500"],
-        cursor: 'pointer'
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem'
+        }
     }
 });
 
@@ -101,17 +101,19 @@ export const DetailsAttribute = connect(mapStateToProps)(withStyles(styles)(
                 valueNode = value;
             }
             return <Typography component="div" className={classes.attribute}>
-                <Typography component="span" className={classnames([classes.label, classLabel])}>{label}</Typography>
+                <Typography component="div" className={classnames([classes.label, classLabel])}>{label}</Typography>
                 <Typography
                     onClick={onValueClick}
-                    component="span"
+                    component="div"
                     className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
                     {valueNode}
                     {children}
                     {linkToUuid && <Tooltip title="Copy">
-                        <CopyToClipboard text={linkToUuid || ""} onCopy={() => this.onCopy("Copied")}>
-                            <CopyIcon className={classes.copyIcon} />
-                        </CopyToClipboard>
+                        <span className={classes.copyIcon}>
+                            <CopyToClipboard text={linkToUuid || ""} onCopy={() => this.onCopy("Copied")}>
+                                <CopyIcon />
+                            </CopyToClipboard>
+                        </span>
                     </Tooltip>}
                 </Typography>
             </Typography>;
index 18adb5abbf375f586bb3158207cb72d11548c30a..55c3c5a50f44759f10bbee681299b5ebe92cc32a 100644 (file)
@@ -56,6 +56,7 @@ import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
 import VpnKey from '@material-ui/icons/VpnKey';
+import LinkOutlined from '@material-ui/icons/LinkOutlined';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
@@ -139,3 +140,4 @@ export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
 export const WorkflowIcon: IconType = (props) => <Code {...props} />;
 export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
+export const Link: IconType = (props) => <LinkOutlined {...props} />;
index a4353d4e0636c08916cee01dfe2149eebe16eb52..569656d9117874646b238616330e25d640aa932e 100644 (file)
@@ -62,6 +62,7 @@ import { processResourceAdminActionSet } from '~/views-components/context-menu/a
 import { 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';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -95,6 +96,8 @@ addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
 
+storeRedirects();
+
 fetchConfig()
     .then(({ config, apiHost }) => {
         const history = createBrowserHistory();
@@ -122,7 +125,7 @@ fetchConfig()
                 }
             }
         });
-        const store = configureStore(history, services);
+        const store = configureStore(history, services, config);
 
         store.subscribe(initListener(history, store, services, config));
         store.dispatch(initAuth(config));
index 90441a645f49d92d622fb0dd689fdaee9604bc51..0aa0aa84de4f476866c60cccb68b5dc2acc9bcb9 100644 (file)
@@ -65,8 +65,8 @@ export class CollectionService extends TrashableResourceService<CollectionResour
             ? this.webdavClient.defaults.baseURL.slice(0, -1)
             : this.webdavClient.defaults.baseURL;
         const apiToken = this.authService.getApiToken();
-        const splittedApiToken = apiToken ? apiToken.split('/') : [];
-        const userApiToken = `/t=${splittedApiToken[2]}/`;
+        const encodedApiToken = apiToken ? encodeURI(apiToken) : '';
+        const userApiToken = `/t=${encodedApiToken}/`;
         const splittedPrevFileUrl = file.url.split('/');
         const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
         return {
index ed2e18b2db39079705805501ed2cc4cc9bb18be6..a02f922df435d9f0b1c9065ef9a9e9f4a317ebd6 100644 (file)
@@ -27,6 +27,7 @@ const getClusterConfig = async (origin: string, apiClient: AxiosInstance): Promi
         configFromDD = {
             baseUrl: normalizeURLPath(dd.baseUrl),
             keepWebServiceUrl: dd.keepWebServiceUrl,
+            keepWebInlineServiceUrl: dd.keepWebInlineServiceUrl,
             remoteHosts: dd.remoteHosts,
             rootUrl: dd.rootUrl,
             uuidPrefix: dd.uuidPrefix,
index 83a699a7d2121d7118c2a37ac08cec1f3dc9733c..13575d44d5ce1ab65196fd0dab4bbf43ecb464a2 100644 (file)
@@ -23,6 +23,7 @@ describe('auth-actions', () => {
 
     let store: RootStore;
     let services: ServiceRepository;
+    const config: any = {};
     const actions: ApiActions = {
         progressFn: (id: string, working: boolean) => { },
         errorFn: (id: string, message: string) => { }
@@ -32,7 +33,7 @@ describe('auth-actions', () => {
     beforeEach(() => {
         axiosMock.reset();
         services = createServices(mockConfig({}), actions, axiosInst);
-        store = configureStore(createBrowserHistory(), services);
+        store = configureStore(createBrowserHistory(), services, config);
         localStorage.clear();
         importMocks = [];
     });
@@ -62,6 +63,7 @@ describe('auth-actions', () => {
             .reply(200, {
                 baseUrl: "https://xc59z.arvadosapi.com/arvados/v1",
                 keepWebServiceUrl: "",
+                keepWebInlineServiceUrl: "",
                 remoteHosts: {},
                 rootUrl: "https://xc59z.arvadosapi.com",
                 uuidPrefix: "xc59z",
index 1fe34381d941372a79b0b3835703895807cffc29..bcc942e1ee76e85f338ed34bc2db80437715d1ef 100644 (file)
@@ -18,6 +18,7 @@ describe("AuthMiddleware", () => {
     let store: RootStore;
     let services: ServiceRepository;
     let axiosInst: AxiosInstance;
+    const config: any = {};
     const actions: ApiActions = {
         progressFn: (id: string, working: boolean) => { },
         errorFn: (id: string, message: string) => { }
@@ -26,7 +27,7 @@ describe("AuthMiddleware", () => {
     beforeEach(() => {
         axiosInst = Axios.create({ headers: {} });
         services = createServices(mockConfig({}), actions, axiosInst);
-        store = configureStore(createBrowserHistory(), services);
+        store = configureStore(createBrowserHistory(), services, config);
         localStorage.clear();
     });
 
diff --git a/src/store/open-in-new-tab/open-in-new-tab.actions.ts b/src/store/open-in-new-tab/open-in-new-tab.actions.ts
new file mode 100644 (file)
index 0000000..17ba740
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { ResourceKind } from '~/models/resource';
+import { unionize, ofType } from '~/common/unionize';
+
+export const openInNewTabActions = unionize({
+    COPY_STORE: ofType<{}>(),
+    OPEN_COLLECTION_IN_NEW_TAB: ofType<string>(),
+    OPEN_PROJECT_IN_NEW_TAB: ofType<string>()
+});
+
+export const openInNewTabAction = (resource: any) => (dispatch: Dispatch) => {
+    const { uuid, kind } = resource;
+
+    dispatch(openInNewTabActions.COPY_STORE());
+
+    if (kind === ResourceKind.COLLECTION) {
+        dispatch(openInNewTabActions.OPEN_COLLECTION_IN_NEW_TAB(uuid));
+    } else if (kind === ResourceKind.PROJECT) {
+        dispatch(openInNewTabActions.OPEN_PROJECT_IN_NEW_TAB(uuid));
+    }
+};
\ No newline at end of file
index 030b657662e8b2631f069b18775aa1c97f71f396..7beb099c1cf9c8f32ba818d064c6d2938b422b57 100644 (file)
@@ -6,6 +6,7 @@ import { createStore, applyMiddleware, compose, Middleware, combineReducers, Sto
 import { routerMiddleware, routerReducer } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
+import { handleRedirects } from '../common/redirect-to';
 
 import { authReducer } from "./auth/auth-reducer";
 import { authMiddleware } from "./auth/auth-middleware";
@@ -68,6 +69,7 @@ import { ownerNameReducer } from '~/store/owner-name/owner-name-reducer';
 import { SubprocessMiddlewareService } from '~/store/subprocess-panel/subprocess-panel-middleware-service';
 import { SUBPROCESS_PANEL_ID } from '~/store/subprocess-panel/subprocess-panel-actions';
 import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action';
+import { Config } from '~/common/config';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -79,7 +81,7 @@ export type RootState = ReturnType<ReturnType<typeof createRootReducer>>;
 
 export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
 
-export function configureStore(history: History, services: ServiceRepository): RootStore {
+export function configureStore(history: History, services: ServiceRepository, config: Config): RootStore {
     const rootReducer = createRootReducer(services);
 
     const projectPanelMiddleware = dataExplorerMiddleware(
@@ -130,6 +132,15 @@ export function configureStore(history: History, services: ServiceRepository): R
     const subprocessMiddleware = dataExplorerMiddleware(
         new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID)
     );
+    const redirectToMiddleware = (store: any) => (next: any) => (action: any) => {
+        const state = store.getState();
+
+        if (state.auth && state.auth.apiToken) {
+            handleRedirects(state.auth.apiToken, config);
+        }
+
+        return next(action);
+    };
 
     const middlewares: Middleware[] = [
         routerMiddleware(history),
@@ -150,9 +161,9 @@ export function configureStore(history: History, services: ServiceRepository): R
         apiClientAuthorizationMiddlewareService,
         publicFavoritesMiddleware,
         collectionsContentAddress,
-        subprocessMiddleware
+        subprocessMiddleware,
     ];
-    const enhancer = composeEnhancers(applyMiddleware(...middlewares));
+    const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares));
     return createStore(rootReducer, enhancer);
 }
 
index b900d1864dd3490710cb39a061caff1b548417cb..6ce62ca942c55e738f79b2b47a295bd7cc220d1a 100644 (file)
@@ -7,7 +7,7 @@ import { RemoveIcon, RenameIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
 import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 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 = [[
     {
@@ -17,6 +17,10 @@ export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
     {
         component: CollectionFileViewerAction,
         execute: () => { return; },
+    },
+    {
+        component: CollectionCopyToClipboardAction,
+        execute: () => { return; },
     }
 ]];
 
diff --git a/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx b/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..4fc11fb
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { getNodeValue } from "~/models/tree";
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { CopyToClipboardAction } from "./copy-to-clipboard-action";
+
+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)) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            return {
+                href: file.url.replace(keepWebServiceUrl, ''),
+                kind: 'file',
+                currentCollectionUuid
+            };
+        }
+    }
+    return {};
+};
+
+export const CollectionCopyToClipboardAction = connect(mapStateToProps)(CopyToClipboardAction);
index 0a202daf21085513acc2ae62f00c43e866393d4c..dfc9d14acd7e5552e5daa23e9256b04ab819f764 100644 (file)
@@ -6,26 +6,24 @@ import { connect } from "react-redux";
 import { RootState } from "../../../store/store";
 import { FileViewerAction } from '~/views-components/context-menu/actions/file-viewer-action';
 import { getNodeValue } from "~/models/tree";
-import { CollectionFileType } from "~/models/collection-file";
 import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 
 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) {
+    if (resource && (
+        resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM ||
+        resource.menuKind === ContextMenuKind.READONLY_COLLECTION_FILES_ITEM)) {
         const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
         if (file) {
             return {
-                href: file.url,
-                download: file.type === CollectionFileType.DIRECTORY ? undefined : file.name,
+                href: file.url.replace(state.auth.config.keepWebServiceUrl, state.auth.config.keepWebInlineServiceUrl),
                 kind: 'file',
                 currentCollectionUuid
             };
         }
-    } else {
-        return ;
     }
-    return ;
+    return {};
 };
 
 export const CollectionFileViewerAction = connect(mapStateToProps)(FileViewerAction);
diff --git a/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx b/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx
new file mode 100644 (file)
index 0000000..1ada703
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { shallow, configure } from 'enzyme';
+import { ListItem } from "@material-ui/core";
+import * as Adapter from 'enzyme-adapter-react-16';
+import { CopyToClipboardAction } from './copy-to-clipboard-action';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('copy-to-clipboard', () => jest.fn());
+
+describe('CopyToClipboardAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<CopyToClipboardAction {...props} />);
+        wrapper.find(ListItem).simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
diff --git a/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx b/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..fffcc19
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import * as copy from 'copy-to-clipboard';
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { Link } from "~/components/icon/icon";
+import { getClipboardUrl } from "./helpers";
+
+export const CopyToClipboardAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
+    const copyToClipboard = () => {
+        if (props.href) {
+            const clipboardUrl = getClipboardUrl(props.href);
+            copy(clipboardUrl);
+        }
+
+        if (props.onClick) {
+            props.onClick();
+        }
+    };
+
+    return props.href
+        ? <ListItem button onClick={copyToClipboard}>
+            <ListItemIcon>
+                <Link />
+            </ListItemIcon>
+            <ListItemText>
+                Copy to clipboard
+            </ListItemText>
+        </ListItem>
+        : null;
+};
\ No newline at end of file
index 7468954fdd97d293837dd8edfebbc0d5a62a241a..86694c8b5f7b1de7bfa179dd59e2e7b5a0790499 100644 (file)
@@ -47,7 +47,7 @@ export const DownloadAction = (props: { href?: any, download?: any, onClick?: ()
     return props.href || props.kind === 'files'
         ? <a
             style={{ textDecoration: 'none' }}
-            href={props.kind === 'files' ? undefined : `${props.href}?disposition=attachment`}
+            href={props.kind === 'files' ? undefined : `${props.href}&disposition=attachment`}
             onClick={props.onClick}
             {...downloadProps}>
             <ListItem button onClick={() => props.kind === 'files' ? createZip(props.href, props.download) : undefined}>
index aadc1d11a14c26c4aa89fccdf52fdf1d39d7f001..e1986d3cd338454551cc46b9563405d097257ab2 100644 (file)
@@ -6,19 +6,20 @@ import { connect } from "react-redux";
 import { RootState } from "../../../store/store";
 import { DownloadAction } from "./download-action";
 import { getNodeValue } from "../../../models/tree";
-import { CollectionFileType } from "../../../models/collection-file";
 import { ContextMenuKind } from '../context-menu';
 import { filterCollectionFilesBySelection } from "~/store/collection-panel/collection-panel-files/collection-panel-files-state";
+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) {
+    if (resource && (
+        resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM ||
+        resource.menuKind === ContextMenuKind.READONLY_COLLECTION_FILES_ITEM)) {
         const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
         if (file) {
             return {
-                href: file.url,
-                download: file.type === CollectionFileType.DIRECTORY ? undefined : file.name,
+                href: sanitizeToken(file.url, true),
                 kind: 'file',
                 currentCollectionUuid
             };
@@ -27,7 +28,6 @@ const mapStateToProps = (state: RootState) => {
         const files = filterCollectionFilesBySelection(state.collectionPanelFiles, true);
         return {
             href: files.map(file => file.url),
-            download: files.map(file => file.name),
             kind: 'files',
             currentCollectionUuid
         };
diff --git a/src/views-components/context-menu/actions/file-viewer-action.test.tsx b/src/views-components/context-menu/actions/file-viewer-action.test.tsx
new file mode 100644 (file)
index 0000000..fa455de
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { shallow, configure } from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import { FileViewerAction } from './file-viewer-action';
+
+configure({ adapter: new Adapter() });
+
+describe('FileViewerAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<FileViewerAction {...props} />);
+        wrapper.find('a').simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
index 20dcece400a2955aeaeeb42ebe55280464aa8bac..a631424e67bde5f46dbca5e370c3e6bdaa70eb29 100644 (file)
@@ -3,27 +3,40 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
+import { connect } from 'react-redux';
 import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
 import { OpenIcon } from "~/components/icon/icon";
+import { sanitizeToken } from "./helpers";
+import { RootState } from "~/store/store";
 
-export const FileViewerAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
-    const fileProps = props.download ? { download: props.download } : {};
+export const FileViewerAction = (props: any) => {
+    const {
+        keepWebServiceUrl,
+        keepWebInlineServiceUrl,
+    } = props;
 
     return props.href
         ? <a
             style={{ textDecoration: 'none' }}
-            href={props.href}
+            href={sanitizeToken(props.href.replace(keepWebServiceUrl, keepWebInlineServiceUrl), true)}
             target="_blank"
-            onClick={props.onClick}
-            {...fileProps}>
+            onClick={props.onClick}>
             <ListItem button>
-                    <ListItemIcon>
-                        <OpenIcon />
-                    </ListItemIcon>
+                <ListItemIcon>
+                    <OpenIcon />
+                </ListItemIcon>
                 <ListItemText>
                     Open in new tab
-                </ListItemText>
+                    </ListItemText>
             </ListItem>
         </a>
         : null;
-};
\ No newline at end of file
+};
+
+const mapStateToProps = ({ auth }: RootState): any => ({
+    keepWebServiceUrl: auth.config.keepWebServiceUrl,
+    keepWebInlineServiceUrl: auth.config.keepWebInlineServiceUrl,
+});
+
+
+export default connect(mapStateToProps, null)(FileViewerAction);
diff --git a/src/views-components/context-menu/actions/helpers.test.ts b/src/views-components/context-menu/actions/helpers.test.ts
new file mode 100644 (file)
index 0000000..4234a8c
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sanitizeToken, getClipboardUrl } from "./helpers";
+
+describe('helpers', () => {
+    // given
+    const url = 'https://example.com/c=zzzzz/t=v2/a/b/LIMS/1.html';
+
+    describe('sanitizeToken', () => {
+        it('should sanitize token from the url', () => {
+            // when
+            const result = sanitizeToken(url);
+
+            // then
+            expect(result).toBe('https://example.com/c=zzzzz/LIMS/1.html?api_token=v2/a/b');
+        });
+    });
+
+    describe('getClipboardUrl', () => {
+        it('should add redirectTo query param', () => {
+            // when
+            const result = getClipboardUrl(url);
+
+            // then
+            expect(result).toBe('http://localhost?redirectTo=https://example.com/c=zzzzz/LIMS/1.html');
+        });
+    });
+});
\ No newline at end of file
diff --git a/src/views-components/context-menu/actions/helpers.ts b/src/views-components/context-menu/actions/helpers.ts
new file mode 100644 (file)
index 0000000..8dfcaca
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const sanitizeToken = (href: string, tokenAsQueryParam: boolean = true): string => {
+    const [prefix, suffix] = href.split('/t=');
+    const [token1, token2, token3, ...rest] = suffix.split('/');
+    const token = `${token1}/${token2}/${token3}`;
+    const sep = href.indexOf("?") > -1 ? "&" : "?";
+
+    return `${[prefix, ...rest].join('/')}${tokenAsQueryParam ? `${sep}api_token=${token}` : ''}`;
+};
+
+export const getClipboardUrl = (href: string): string => {
+    const { origin } = window.location;
+    const url = sanitizeToken(href, false);
+
+    return `${origin}?redirectTo=${url}`;
+};
index f2b7c131e9ac5c823625c92a158fc1d42478d915..d2457559e4d8207189df0c0818a9b698e60643c1 100644 (file)
@@ -30,7 +30,7 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
     }
 
     private getCollectionInfo() {
-        return <CollectionDetailsAttributes item={this.item} />;
+        return <CollectionDetailsAttributes twoCol={false} item={this.item} />;
     }
 
     private getVersionBrowser() {
index d3566f36ff45f515344874a6f5d14b28b58c7e31..feade60c1d500c86b9655f84beff95793dd434bd 100644 (file)
@@ -14,7 +14,7 @@ import { ArvadosTheme } from '~/common/custom-theme';
 import { RootState } from '~/store/store';
 import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon, ExpandIcon, CollectionOldVersionIcon } from '~/components/icon/icon';
 import { DetailsAttribute } from '~/components/details-attribute/details-attribute';
-import { CollectionResource } from '~/models/collection';
+import { CollectionResource, getCollectionUrl } from '~/models/collection';
 import { CollectionPanelFiles } from '~/views-components/collection-panel-files/collection-panel-files';
 import { CollectionTagForm } from './collection-tag-form';
 import { deleteCollectionTag, navigateToProcess, collectionPanelActions } from '~/store/collection-panel/collection-panel-action';
@@ -31,6 +31,7 @@ import { UserResource } from '~/models/user';
 import { getUserUuid } from '~/common/getuser';
 import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer';
 import { COLLECTION_PANEL_LOAD_FILES, loadCollectionFiles, COLLECTION_PANEL_LOAD_FILES_THRESHOLD } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
+import { Link } from 'react-router-dom';
 
 type CssRules = 'root'
     | 'filesCard'
@@ -113,7 +114,7 @@ export const CollectionPanel = withStyles(styles)(
             if (item.ownerUuid === currentUserUUID) {
                 isWritable = true;
             } else {
-                const itemOwner = getResource<GroupResource|UserResource>(item.ownerUuid)(state.resources);
+                const itemOwner = getResource<GroupResource | UserResource>(item.ownerUuid)(state.resources);
                 if (itemOwner) {
                     isWritable = itemOwner.writableBy.indexOf(currentUserUUID || '') >= 0;
                 }
@@ -134,21 +135,21 @@ export const CollectionPanel = withStyles(styles)(
                                 <Grid container justify="space-between">
                                     <Grid item xs={11}><span>
                                         <IconButton onClick={this.openCollectionDetails}>
-                                            { isOldVersion
-                                            ? <CollectionOldVersionIcon className={classes.iconHeader} />
-                                            : <CollectionIcon className={classes.iconHeader} /> }
+                                            {isOldVersion
+                                                ? <CollectionOldVersionIcon className={classes.iconHeader} />
+                                                : <CollectionIcon className={classes.iconHeader} />}
                                         </IconButton>
-                                        <IllegalNamingWarning name={item.name}/>
+                                        <IllegalNamingWarning name={item.name} />
                                         <span>
                                             {item.name}
                                             {isWritable ||
-                                            <Tooltip title="Read-only">
-                                                <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
-                                            </Tooltip>
+                                                <Tooltip title="Read-only">
+                                                    <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
+                                                </Tooltip>
                                             }
                                         </span>
                                     </span></Grid>
-                                    <Grid item xs={1} style={{textAlign: "right"}}>
+                                    <Grid item xs={1} style={{ textAlign: "right" }}>
                                         <Tooltip title="Actions" disableFocusListener>
                                             <IconButton
                                                 data-cy='collection-panel-options-btn'
@@ -166,16 +167,16 @@ export const CollectionPanel = withStyles(styles)(
                                         <Typography variant="caption">
                                             {item.description}
                                         </Typography>
-                                        <CollectionDetailsAttributes item={item} classes={classes} />
+                                        <CollectionDetailsAttributes item={item} classes={classes} twoCol={true} />
                                         {(item.properties.container_request || item.properties.containerRequest) &&
                                             <span onClick={() => dispatch<any>(navigateToProcess(item.properties.container_request || item.properties.containerRequest))}>
                                                 <DetailsAttribute classLabel={classes.link} label='Link to process' />
                                             </span>
                                         }
                                         {isOldVersion &&
-                                        <Typography className={classes.warningLabel} variant="caption">
-                                            This is an old version. Copy it as a new one if you need to make changes. Go to the current version if you need to share it.
-                                        </Typography>
+                                            <Typography className={classes.warningLabel} variant="caption">
+                                                This is an old version. Make a copy to make changes. Go to the <Link to={getCollectionUrl(item.currentVersionUuid)}>head version</Link> for sharing options.
+                                          </Typography>
                                         }
                                     </Grid>
                                 </Grid>
@@ -192,25 +193,25 @@ export const CollectionPanel = withStyles(styles)(
                                         <CollectionTagForm />
                                     </Grid>}
                                     <Grid item xs={12}>
-                                    Object.keys(item.properties).length > 0
-                                        ? Object.keys(item.properties).map(k =>
-                                            Array.isArray(item.properties[k])
-                                            ? item.properties[k].map((v: string) =>
-                                                getPropertyChip(
-                                                    k, v,
-                                                    isWritable
-                                                        ? this.handleDelete(k, item.properties[k])
-                                                        : undefined,
-                                                    classes.tag))
-                                            : getPropertyChip(
-                                                k, item.properties[k],
-                                                isWritable
-                                                    ? this.handleDelete(k, item.properties[k])
-                                                    : undefined,
-                                                classes.tag)
-                                        )
-                                        : <div className={classes.centeredLabel}>No properties set on this collection.</div>
-                                    }
+                                        {Object.keys(item.properties).length > 0
+                                            ? Object.keys(item.properties).map(k =>
+                                                Array.isArray(item.properties[k])
+                                                    ? item.properties[k].map((v: string) =>
+                                                        getPropertyChip(
+                                                            k, v,
+                                                            isWritable
+                                                                ? this.handleDelete(k, item.properties[k])
+                                                                : undefined,
+                                                            classes.tag))
+                                                    : getPropertyChip(
+                                                        k, item.properties[k],
+                                                        isWritable
+                                                            ? this.handleDelete(k, item.properties[k])
+                                                            : undefined,
+                                                        classes.tag)
+                                            )
+                                            : <div className={classes.centeredLabel}>No properties set on this collection.</div>
+                                        }
                                     </Grid>
                                 </Grid>
                             </ExpansionPanelDetails>
@@ -224,7 +225,7 @@ export const CollectionPanel = withStyles(styles)(
                                     dispatch(collectionPanelActions.LOAD_BIG_COLLECTIONS(true));
                                     dispatch<any>(loadCollectionFiles(this.props.item.uuid));
                                 }
-                            } />
+                                } />
                         </div>
                     </div>
                     : null;
@@ -277,31 +278,51 @@ export const CollectionPanel = withStyles(styles)(
     )
 );
 
-export const CollectionDetailsAttributes = (props: {item: CollectionResource, classes?: Record<CssRules, string>}) => {
+export const CollectionDetailsAttributes = (props: { item: CollectionResource, twoCol: boolean, classes?: Record<CssRules, string> }) => {
     const item = props.item;
-    const classes = props.classes || {label: '', value: ''};
+    const classes = props.classes || { label: '', value: '' };
     const isOldVersion = item && item.currentVersionUuid !== item.uuid;
-    return <span>
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label={isOldVersion ? "This version's UUID" : "Collection UUID"}
-            linkToUuid={item.uuid} />
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label={isOldVersion ? "This version's PDH" : "Portable data hash"}
-            linkToUuid={item.portableDataHash} />
+    const mdSize = props.twoCol ? 6 : 12;
+    return <Grid container>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label={isOldVersion ? "This version's UUID" : "Collection UUID"}
+                linkToUuid={item.uuid} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label={isOldVersion ? "This version's PDH" : "Portable data hash"}
+                linkToUuid={item.portableDataHash} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Owner' linkToUuid={item.ownerUuid} />
+        </Grid>
+
         {isOldVersion &&
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label='Most recent version'
-            linkToUuid={item.currentVersionUuid} />
+            <Grid item xs={12} md={mdSize}>
+                <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                    label='Head version'
+                    linkToUuid={item.currentVersionUuid} />
+            </Grid>
         }
-        <DetailsAttribute label='Last modified' value={formatDate(item.modifiedAt)} />
-        <DetailsAttribute label='Created at' value={formatDate(item.createdAt)} />
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label='Version number' value={item.version} />
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label='Number of files' value={item.fileCount} />
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label='Content size' value={formatFileSize(item.fileSizeTotal)} />
-        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-            label='Owner' linkToUuid={item.ownerUuid} />
-    </span>;
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Version number' value={item.version} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Created at' value={formatDate(item.createdAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Last modified' value={formatDate(item.modifiedAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Number of files' value={item.fileCount} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Content size' value={formatFileSize(item.fileSizeTotal)} />
+        </Grid>
+    </Grid>;
 };