Merge branch '18123-group-edit-page-rebase1' into main. Closes #18123
authorStephen Smith <stephen@curii.com>
Thu, 16 Dec 2021 17:27:26 +0000 (12:27 -0500)
committerStephen Smith <stephen@curii.com>
Thu, 16 Dec 2021 17:27:26 +0000 (12:27 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

64 files changed:
.licenseignore
cypress/fixtures/files/5mb.bin [new file with mode: 0644]
cypress/integration/collection.spec.js
cypress/integration/favorites.spec.js
cypress/integration/project.spec.js
cypress/support/commands.js
src/common/webdav.ts
src/components/chips/chips.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/collection-panel-files/collection-panel-files2.test.tsx [deleted file]
src/components/collection-panel-files/collection-panel-files2.tsx [deleted file]
src/components/data-explorer/data-explorer.tsx
src/components/data-table/data-table.tsx
src/components/file-upload/file-upload.tsx
src/components/form-dialog/form-dialog.tsx
src/components/icon/icon.tsx
src/components/multi-panel-view/multi-panel-view.test.tsx [new file with mode: 0644]
src/components/multi-panel-view/multi-panel-view.tsx [new file with mode: 0644]
src/components/panel-default-view/panel-default-view.tsx [deleted file]
src/components/search-input/search-input.test.tsx
src/components/search-input/search-input.tsx
src/services/collection-service/collection-service.test.ts
src/services/collection-service/collection-service.ts
src/services/common-service/common-service.ts
src/store/advanced-tab/advanced-tab.tsx
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-upload-actions.ts
src/store/collections/collection-version-actions.ts
src/store/file-uploader/file-uploader-actions.ts
src/store/file-uploader/file-uploader-reducer.ts
src/store/process-logs-panel/process-logs-panel.ts
src/views-components/context-menu/action-sets/collection-files-action-set.ts
src/views-components/details-panel/process-details.tsx
src/views-components/dialog-upload/dialog-collection-files-upload.tsx
src/views-components/file-uploader/file-uploader.tsx
src/views-components/main-content-bar/main-content-bar.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/snackbar/snackbar.tsx
src/views/all-processes-panel/all-processes-panel.tsx
src/views/api-client-authorization-panel/api-client-authorization-panel-root.tsx
src/views/api-client-authorization-panel/api-client-authorization-panel.tsx
src/views/collection-content-address-panel/collection-content-address-panel.tsx
src/views/collection-panel/collection-panel.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/group-details-panel/group-details-panel.tsx
src/views/groups-panel/groups-panel.tsx
src/views/link-panel/link-panel-root.tsx
src/views/process-panel/process-details-attributes.tsx [new file with mode: 0644]
src/views/process-panel/process-details-card.tsx [new file with mode: 0644]
src/views/process-panel/process-information-card.tsx
src/views/process-panel/process-panel-root.tsx
src/views/project-panel/project-panel.tsx
src/views/public-favorites-panel/public-favorites-panel.tsx
src/views/run-process-panel/inputs/float-array-input.tsx
src/views/run-process-panel/inputs/int-array-input.tsx
src/views/run-process-panel/inputs/string-array-input.tsx
src/views/run-process-panel/run-process-first-step.tsx
src/views/shared-with-me-panel/shared-with-me-panel.tsx
src/views/subprocess-panel/subprocess-panel-root.tsx
src/views/trash-panel/trash-panel.tsx
src/views/user-panel/user-panel.tsx
src/views/workbench/workbench.tsx

index 853135fc885f09ad99bfec03cfc12ec0190560f6..9b943a1f3f4a78f73af2e25e0acffec5f4824354 100644 (file)
@@ -14,3 +14,4 @@ public/*
 .npmrc
 src/lib/cwl-svg/*
 tools/arvados_config.yml
+cypress/fixtures/files/5mb.bin
diff --git a/cypress/fixtures/files/5mb.bin b/cypress/fixtures/files/5mb.bin
new file mode 100644 (file)
index 0000000..d52f252
Binary files /dev/null and b/cypress/fixtures/files/5mb.bin differ
index 3e06d7e5a0b2bb03b3de36e2cecfe1241b4dbdd0..82a26cef6f325993a199643934a26ab7b2d0b43e 100644 (file)
@@ -595,6 +595,86 @@ describe('Collection panel tests', function () {
         })
     });
 
+    it('moves a collection to a different project', function () {
+        const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+        const projName = `Test Project ${Math.floor(Math.random() * 999999)}`;
+        const fileName = `Test_File_${Math.floor(Math.random() * 999999)}`;
+
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
+        }).as('testCollection');
+        cy.createGroup(adminUser.token, {
+            name: projName,
+            group_class: 'project',
+            owner_uuid: activeUser.user.uuid,
+        }).as('testProject');
+
+        cy.getAll('@testCollection', '@testProject')
+            .then(function ([testCollection, testProject]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
+                cy.get('[data-cy=collection-files-panel]').should('contain', fileName);
+                cy.get('[data-cy=collection-info-panel]')
+                    .should('not.contain', projName)
+                    .and('not.contain', testProject.uuid);
+                cy.get('[data-cy=collection-panel-options-btn]').click();
+                cy.get('[data-cy=context-menu]').contains('Move to').click();
+                cy.get('[data-cy=form-dialog]')
+                    .should('contain', 'Move to')
+                    .within(() => {
+                        cy.get('[data-cy=projects-tree-home-tree-picker]')
+                            .find('i')
+                            .click();
+                        cy.get('[data-cy=projects-tree-home-tree-picker]')
+                            .contains(projName)
+                            .click();
+                    });
+                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get('[data-cy=snackbar]')
+                    .contains('Collection has been moved')
+                cy.get('[data-cy=collection-info-panel]')
+                    .contains(projName).and('contain', testProject.uuid);
+                // Double check that the collection is in the project
+                cy.goToPath(`/projects/${testProject.uuid}`);
+                cy.get('[data-cy=project-panel]').should('contain', collName);
+            });
+    });
+
+    it('makes a copy of an existing collection', function() {
+        const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
+        const copyName = `Copy of: ${collName}`;
+
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
+        }).as('collection').then(function () {
+            cy.loginAs(activeUser)
+            cy.goToPath(`/collections/${this.collection.uuid}`);
+            cy.get('[data-cy=collection-files-panel]')
+                .should('contain', 'some-file');
+            cy.get('[data-cy=collection-panel-options-btn]').click();
+            cy.get('[data-cy=context-menu]').contains('Make a copy').click();
+            cy.get('[data-cy=form-dialog]')
+                .should('contain', 'Make a copy')
+                .within(() => {
+                    cy.get('[data-cy=projects-tree-home-tree-picker]')
+                        .contains('Projects')
+                        .click();
+                    cy.get('[data-cy=form-submit-btn]').click();
+                });
+            cy.get('[data-cy=snackbar]')
+                .contains('Collection has been copied.')
+            cy.get('[data-cy=snackbar-goto-action]').click();
+            cy.get('[data-cy=project-panel]')
+                .contains(copyName).click();
+            cy.get('[data-cy=collection-files-panel]')
+                .should('contain', 'some-file');
+        });
+    });
+
     it('uses the collection version browser to view a previous version', function () {
         const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
 
@@ -793,4 +873,84 @@ describe('Collection panel tests', function () {
                     .contains(adminUser.user.uuid);
             });
     });
+
+    describe('file upload', () => {
+        beforeEach(() => {
+            cy.createCollection(adminUser.token, {
+                name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: activeUser.user.uuid,
+                manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            })
+                .as('testCollection1');
+        });
+
+        it('allows to cancel running upload', () => {
+            cy.getAll('@testCollection1')
+                .then(function([testCollection1]) {
+                    cy.loginAs(activeUser);
+
+                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                    cy.get('[data-cy=upload-button]').click();
+
+                    cy.fixture('files/5mb.bin', 'base64').then(content => {
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+
+                        cy.get('[data-cy=form-submit-btn]').click();
+
+                        cy.get('button').contains('Cancel').click();
+
+                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
+                    });
+                });
+        });
+
+        it('allows to cancel single file from the running upload', () => {
+            cy.getAll('@testCollection1')
+                .then(function([testCollection1]) {
+                    cy.loginAs(activeUser);
+
+                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                    cy.get('[data-cy=upload-button]').click();
+
+                    cy.fixture('files/5mb.bin', 'base64').then(content => {
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+
+                        cy.get('[data-cy=form-submit-btn]').click();
+
+                        cy.get('button[aria-label=Remove]').eq(1).click();
+
+                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
+
+                        cy.get('[data-cy=collection-files-panel]').contains('5mb_a.bin').should('exist');
+                    });
+                });
+        });
+
+        it('allows to cancel all files from the running upload', () => {
+            cy.getAll('@testCollection1')
+                .then(function([testCollection1]) {
+                    cy.loginAs(activeUser);
+
+                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                    cy.get('[data-cy=upload-button]').click();
+
+                    cy.fixture('files/5mb.bin', 'base64').then(content => {
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
+                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+
+                        cy.get('[data-cy=form-submit-btn]').click();
+
+                        cy.get('button[aria-label=Remove]').should('exist');
+                        cy.get('button[aria-label=Remove]').click({ multiple: true, force: true });
+
+                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
+                    });
+                });
+        });
+    });
 })
index 13a2c4675f7492f2f872e63e38c2ba7df4c0da37..9bc90ebdee4c9fda24b91a2c9c15312965d3fd46 100644 (file)
@@ -44,7 +44,8 @@ describe('Favorites tests', function () {
         });
     });
 
-    it('can copy selected into the collection', () => {
+    // Disabled while addressing #18587
+    it.skip('can copy selected into the collection', () => {
         cy.createCollection(adminUser.token, {
             name: `Test source collection ${Math.floor(Math.random() * 999999)}`,
             manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
index af2d93e3c605ec79f1538c9224ce09800489bbfd..b3d6bbed83b657ab3990c0aeb435aa1a34b8e67a 100644 (file)
@@ -179,4 +179,38 @@ describe('Project tests', function() {
             cy.get('[data-cy=not-found-page]').should('not.exist');
         });
     });
+
+    it('shows details panel when clicking on the info icon', () => {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('testRootProject').then(function(testRootProject) {
+            cy.loginAs(activeUser);
+
+            cy.get('[data-cy=side-panel-tree]').contains(testRootProject.name).click();
+
+            cy.get('[data-cy=additional-info-icon]').click();
+
+            cy.contains(testRootProject.uuid).should('exist');
+        });
+    });
+
+    it('clears search input when changing project', () => {
+        cy.createGroup(activeUser.token, {
+            name: `Test root project ${Math.floor(Math.random() * 999999)}`,
+            group_class: 'project',
+        }).as('testProject1');
+
+        cy.getAll('@testProject1').then(function([testProject1]) {
+            cy.loginAs(activeUser);
+
+            cy.get('[data-cy=side-panel-tree]').contains(testProject1.name).click();
+
+            cy.get('[data-cy=search-input] input').type('test123');
+
+            cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+
+            cy.get('[data-cy=search-input] input').should('not.have.value', 'test123');
+        });
+    });
 });
\ No newline at end of file
index 069ed96dcf3631afec10e55478f6f635f96b6d4b..07290e550aa3b6beceba61559a477216ef220435 100644 (file)
@@ -280,4 +280,42 @@ Cypress.Commands.add('createProject', ({
             cy.addToFavorites(user.token, user.user.uuid, project.uuid);
         }
     });
-});
\ No newline at end of file
+});
+
+Cypress.Commands.add(
+    'upload',
+    {
+        prevSubject: 'element',
+    },
+    (subject, file, fileName) => {
+        cy.window().then(window => {
+            const blob = b64toBlob(file, '', 512);
+            const testFile = new window.File([blob], fileName);
+
+            cy.wrap(subject).trigger('drop', {
+                dataTransfer: { files: [testFile] },
+            });
+        })
+    }
+)
+
+function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
+    const byteCharacters = atob(b64Data)
+    const byteArrays = []
+
+    for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+        const slice = byteCharacters.slice(offset, offset + sliceSize);
+
+        const byteNumbers = new Array(slice.length);
+        for (let i = 0; i < slice.length; i++) {
+            byteNumbers[i] = slice.charCodeAt(i);
+        }
+
+        const byteArray = new Uint8Array(byteNumbers);
+
+        byteArrays.push(byteArray);
+    }
+
+    const blob = new Blob(byteArrays, { type: contentType });
+    return blob
+}
\ No newline at end of file
index 758a5e18e1e9ad44015b9a802d0e532e70ba3d76..93ec21cb724f26d2ede4cf9be4b211defaf85b9c 100644 (file)
@@ -84,6 +84,15 @@ export class WebDAV {
                 .keys(headers)
                 .forEach(key => r.setRequestHeader(key, headers[key]));
 
+            if (!(window as any).cancelTokens) {
+                Object.assign(window, { cancelTokens: {} });
+            }
+
+            (window as any).cancelTokens[config.url] = () => { 
+                resolve(r);
+                r.abort();
+            }
+
             if (config.onUploadProgress) {
                 r.upload.addEventListener('progress', config.onUploadProgress);
             }
index eb68ed7a257942a6fb554f80b544089d93fcaa43..c4724d1bb1a8d9b0ffcb3c79b6127c1a1fddd6c1 100644 (file)
@@ -38,7 +38,7 @@ export const Chips = withStyles(styles)(
         render() {
             const { values, filler } = this.props;
             return <Grid container spacing={8} className={this.props.classes.root}>
-                {values.map(this.renderChip)}
+                {values && values.map(this.renderChip)}
                 {filler && <Grid item xs>{filler}</Grid>}
             </Grid>;
         }
index 97cbc8ce6bc924a2def5e0b88f1261a2e8e8e331..1ef6b5c94cdf117ea52a932c47a085957ce6c2d2 100644 (file)
@@ -48,7 +48,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     wrapper: {
         display: 'flex',
         minHeight: '600px',
-        marginBottom: '1rem',
         color: 'rgba(0, 0, 0, 0.87)',
         fontSize: '0.875rem',
         fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@@ -463,7 +462,7 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState
                         </IconButton>
                     </Tooltip>
                     <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
-                        <SearchInput label="Search" value={leftSearch} onSearch={setLeftSearch} />
+                        <SearchInput selfClearProp={leftKey} label="Search" value={leftSearch} onSearch={setLeftSearch} />
                     </div>
                     <div className={classes.dataWrapper}>
                         {
@@ -490,7 +489,7 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState
                                                         data-parent-path={name}
                                                         className={classNames(classes.row, getActiveClass(name))}
                                                         key={id}>
-                                                            {getItemIcon(type, getActiveClass(name))} 
+                                                            {getItemIcon(type, getActiveClass(name))}
                                                             <div className={classes.rowName}>
                                                                 {name}
                                                             </div>
@@ -510,14 +509,19 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState
                 </div>
                 <div className={classes.rightPanel}>
                     <div className={classes.searchWrapper}>
-                        <SearchInput label="Search" value={rightSearch} onSearch={setRightSearch} />
+                        <SearchInput selfClearProp={rightKey} label="Search" value={rightSearch} onSearch={setRightSearch} />
                     </div>
                     {
                         isWritable &&
                         <Button
                             className={classes.uploadButton}
                             data-cy='upload-button'
-                            onClick={onUploadDataClick}
+                            onClick={() => {
+                                if (!collectionAutofetchEnabled) {
+                                    setCollectionAutofetchEnabled(true);
+                                }
+                                onUploadDataClick();
+                            }}
                             variant='contained'
                             color='primary'
                             size='small'>
diff --git a/src/components/collection-panel-files/collection-panel-files2.test.tsx b/src/components/collection-panel-files/collection-panel-files2.test.tsx
deleted file mode 100644 (file)
index 4d8b815..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from "react";
-import { configure, shallow, mount } from "enzyme";
-import { WithStyles } from "@material-ui/core";
-import Adapter from "enzyme-adapter-react-16";
-import { TreeItem, TreeItemStatus } from '../tree/tree';
-import { FileTreeData } from '../file-tree/file-tree-data';
-import { CollectionFileType } from "../../models/collection-file";
-import { CollectionPanelFilesComponent, CollectionPanelFilesProps, CssRules } from './collection-panel-files2';
-import { SearchInput } from '../search-input/search-input';
-
-configure({ adapter: new Adapter() });
-
-jest.mock('components/file-tree/file-tree', () => ({
-    FileTree: () => 'FileTree',
-}));
-
-describe('<CollectionPanelFiles />', () => {
-    let props: CollectionPanelFilesProps & WithStyles<CssRules>;
-
-    beforeEach(() => {
-        props = {
-            classes: {} as Record<CssRules, string>,
-            items: [],
-            isWritable: true,
-            isLoading: false,
-            tooManyFiles: false,
-            onUploadDataClick: jest.fn(),
-            onSearchChange: jest.fn(),
-            onItemMenuOpen: jest.fn(),
-            onOptionsMenuOpen: jest.fn(),
-            onSelectionToggle: jest.fn(),
-            onCollapseToggle: jest.fn(),
-            onFileClick: jest.fn(),
-            loadFilesFunc: jest.fn(),
-            currentItemUuid: '',
-        };
-    });
-
-    it('renders properly', () => {
-        // when
-        const wrapper = shallow(<CollectionPanelFilesComponent {...props} />);
-
-        // then
-        expect(wrapper).not.toBeUndefined();
-    });
-
-    it('filters out files', () => {
-        // given
-        const searchPhrase = 'test';
-        const items: Array<TreeItem<FileTreeData>> = [
-            {
-                data: {
-                    url: '',
-                    type: CollectionFileType.DIRECTORY,
-                    name: 'test',
-                },
-                id: '1',
-                open: true,
-                active: true,
-                status: TreeItemStatus.LOADED,
-            },
-            {
-                data: {
-                    url: '',
-                    type: CollectionFileType.FILE,
-                    name: 'test123',
-                },
-                id: '2',
-                open: true,
-                active: true,
-                status: TreeItemStatus.LOADED,
-            },
-            {
-                data: {
-                    url: '',
-                    type: CollectionFileType.FILE,
-                    name: 'another-file',
-                },
-                id: '3',
-                open: true,
-                active: true,
-                status: TreeItemStatus.LOADED,
-            }
-        ];
-
-        // setup
-        props.items = items;
-        const wrapper = mount(<CollectionPanelFilesComponent {...props} />);
-        wrapper.find(SearchInput).simulate('change', { target: { value: searchPhrase } });
-
-        // when
-        setTimeout(() => { // we have to use set timeout because of the debounce
-            expect(wrapper.find('FileTree').prop('items'))
-            .toEqual([
-                {
-                    data: { url: '', type: 'directory', name: 'test' },
-                    id: '1',
-                    open: true,
-                    active: true,
-                    status: 'loaded'
-                },
-                {
-                    data: { url: '', type: 'file', name: 'test123' },
-                    id: '2',
-                    open: true,
-                    active: true,
-                    status: 'loaded'
-                }
-            ]);
-        }, 0);
-    });
-});
\ No newline at end of file
diff --git a/src/components/collection-panel-files/collection-panel-files2.tsx b/src/components/collection-panel-files/collection-panel-files2.tsx
deleted file mode 100644 (file)
index 4118248..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { TreeItem, TreeItemStatus } from 'components/tree/tree';
-import { FileTreeData } from 'components/file-tree/file-tree-data';
-import { FileTree } from 'components/file-tree/file-tree';
-import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button, Tooltip, CircularProgress } from '@material-ui/core';
-import { CustomizeTableIcon } from 'components/icon/icon';
-import { DownloadIcon } from 'components/icon/icon';
-import { SearchInput } from '../search-input/search-input';
-
-export interface CollectionPanelFilesProps {
-    items: Array<TreeItem<FileTreeData>>;
-    isWritable: boolean;
-    isLoading: boolean;
-    tooManyFiles: boolean;
-    onUploadDataClick: () => void;
-    onSearchChange: (searchValue: string) => void;
-    onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
-    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
-    onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
-    onCollapseToggle: (id: string, status: TreeItemStatus) => void;
-    onFileClick: (id: string) => void;
-    loadFilesFunc: () => void;
-    currentItemUuid?: string;
-}
-
-export type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader' | 'uploadIcon' | 'button' | 'centeredLabel' | 'cardHeaderContent' | 'cardHeaderContentTitle';
-
-const styles: StyleRulesCallback<CssRules> = theme => ({
-    root: {
-        paddingBottom: theme.spacing.unit,
-        height: '100%'
-    },
-    cardSubheader: {
-        paddingTop: 0,
-        paddingBottom: 0,
-        minHeight: 8 * theme.spacing.unit,
-    },
-    cardHeaderContent: {
-        display: 'flex',
-        paddingRight: 2 * theme.spacing.unit,
-        justifyContent: 'space-between',
-    },
-    cardHeaderContentTitle: {
-        paddingLeft: theme.spacing.unit,
-        paddingTop: 2 * theme.spacing.unit,
-        paddingRight: 2 * theme.spacing.unit,
-    },
-    nameHeader: {
-        marginLeft: '75px'
-    },
-    fileSizeHeader: {
-        marginRight: '65px'
-    },
-    uploadIcon: {
-        transform: 'rotate(180deg)'
-    },
-    button: {
-        marginRight: -theme.spacing.unit,
-        marginTop: '8px'
-    },
-    centeredLabel: {
-        fontSize: '0.875rem',
-        textAlign: 'center'
-    },
-});
-
-export const CollectionPanelFilesComponent = ({ onItemMenuOpen, onSearchChange, onOptionsMenuOpen, onUploadDataClick, classes,
-    isWritable, isLoading, tooManyFiles, loadFilesFunc, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) => {
-    const { useState, useEffect } = React;
-    const [searchValue, setSearchValue] = useState('');
-
-    useEffect(() => {
-        onSearchChange(searchValue);
-    }, [onSearchChange, searchValue]);
-
-    return (<Card data-cy='collection-files-panel' className={classes.root}>
-        <CardHeader
-            title={
-                <div className={classes.cardHeaderContent}>
-                    <span className={classes.cardHeaderContentTitle}>Files</span>
-                    <SearchInput
-                        value={searchValue}
-                        label='Search files'
-                        onSearch={setSearchValue} />
-                </div>
-            }
-            className={classes.cardSubheader}
-            classes={{ action: classes.button }}
-            action={<>
-                {isWritable &&
-                    <Button
-                        data-cy='upload-button'
-                        onClick={onUploadDataClick}
-                        variant='contained'
-                        color='primary'
-                        size='small'>
-                        <DownloadIcon className={classes.uploadIcon} />
-                    Upload data
-                </Button>}
-                {!tooManyFiles &&
-                    <Tooltip title="More options" disableFocusListener>
-                        <IconButton
-                            data-cy='collection-files-panel-options-btn'
-                            onClick={(ev) => onOptionsMenuOpen(ev, isWritable)}>
-                            <CustomizeTableIcon />
-                        </IconButton>
-                    </Tooltip>}
-            </>
-            } />
-        {tooManyFiles
-            ? <div className={classes.centeredLabel}>
-                File listing may take some time, please click to browse: <Button onClick={loadFilesFunc}><DownloadIcon />Show files</Button>
-            </div>
-            : <>
-                <Grid container justify="space-between">
-                    <Typography variant="caption" className={classes.nameHeader}>
-                        Name
-                    </Typography>
-                    <Typography variant="caption" className={classes.fileSizeHeader}>
-                        File size
-                    </Typography>
-                </Grid>
-                {isLoading
-                    ? <div className={classes.centeredLabel}><CircularProgress /></div>
-                    : <div style={{ height: 'calc(100% - 60px)' }}>
-                        <FileTree
-                            onMenuOpen={(ev, item) => onItemMenuOpen(ev, item, isWritable)}
-                            {...treeProps} /></div>}
-            </>
-        }
-    </Card>);
-};
-
-export const CollectionPanelFiles = withStyles(styles)(CollectionPanelFilesComponent);
index 928d3ed4867e68388ed1a416e25b51f4018a1c31..55840ae9fd52a752f8b90e73c1f3cc06370c19b9 100644 (file)
@@ -11,17 +11,19 @@ import { SearchInput } from 'components/search-input/search-input';
 import { ArvadosTheme } from "common/custom-theme";
 import { createTree } from 'models/tree';
 import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
-import { MoreOptionsIcon } from 'components/icon/icon';
+import { CloseIcon, MaximizeIcon, MoreOptionsIcon } from 'components/icon/icon';
 import { PaperProps } from '@material-ui/core/Paper';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 
-type CssRules = 'searchBox' | "toolbar" | "toolbarUnderTitle" | "footer" | "root" | 'moreOptionsButton' | 'title';
+type CssRules = 'searchBox' | "toolbar" | "toolbarUnderTitle" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     searchBox: {
         paddingBottom: theme.spacing.unit * 2
     },
     toolbar: {
-        paddingTop: theme.spacing.unit * 2
+        paddingTop: theme.spacing.unit,
+        paddingRight: theme.spacing.unit * 2,
     },
     toolbarUnderTitle: {
         paddingTop: 0
@@ -30,7 +32,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         overflow: 'auto'
     },
     root: {
-        height: '100%'
+        height: '100%',
     },
     moreOptionsButton: {
         padding: 0
@@ -39,7 +41,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         paddingLeft: theme.spacing.unit * 3,
         paddingTop: theme.spacing.unit * 3,
         fontSize: '18px'
-    }
+    },
+    dataTable: {
+        height: '100%',
+        overflow: 'auto',
+    },
+    container: {
+        height: '100%',
+    },
 });
 
 interface DataExplorerDataProps<T> {
@@ -79,32 +88,39 @@ interface DataExplorerActionProps<T> {
     extractKey?: (item: T) => React.Key;
 }
 
-type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules>;
+type DataExplorerProps<T> = DataExplorerDataProps<T> &
+    DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
 
 export const DataExplorer = withStyles(styles)(
     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
+
         componentDidMount() {
             if (this.props.onSetColumns) {
                 this.props.onSetColumns(this.props.columns);
             }
         }
+
         render() {
             const {
                 columns, onContextMenu, onFiltersChange, onSortToggle, working, extractKey,
                 rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch,
                 items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
                 dataTableDefaultView, hideColumnSelector, actions, paperProps, hideSearchInput,
-                paperKey, fetchMode, currentItemUuid, title
+                paperKey, fetchMode, currentItemUuid, title,
+                doHidePanel, doMaximizePanel, panelName, panelMaximized
             } = this.props;
+
             const dataCy = this.props["data-cy"];
             return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={dataCy}>
-                {title && <div className={classes.title}>{title}</div>}
-                {(!hideColumnSelector || !hideSearchInput || !!actions) && <Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
+                <Grid container direction="column" wrap="nowrap" className={classes.container}>
+                {title && <Grid item xs className={classes.title}>{title}</Grid>}
+                {(!hideColumnSelector || !hideSearchInput || !!actions) && <Grid item xs><Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
                     <Grid container justify="space-between" wrap="nowrap" alignItems="center">
                         {!hideSearchInput && <div className={classes.searchBox}>
                             {!hideSearchInput && <SearchInput
                                 label={searchLabel}
                                 value={searchValue}
+                                selfClearProp={currentItemUuid}
                                 onSearch={onSearch} />}
                         </div>}
                         {actions}
@@ -112,8 +128,16 @@ export const DataExplorer = withStyles(styles)(
                             columns={columns}
                             onColumnToggle={onColumnToggle} />}
                     </Grid>
-                </Toolbar>}
-                <DataTable
+                    { doMaximizePanel && !panelMaximized &&
+                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
+                        </Tooltip> }
+                    { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
+                </Toolbar></Grid>}
+                <Grid item xs="auto" className={classes.dataTable}><DataTable
                     columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
                     items={items}
                     onRowClick={(_, item: T) => onRowClick(item)}
@@ -125,8 +149,8 @@ export const DataExplorer = withStyles(styles)(
                     working={working}
                     defaultView={dataTableDefaultView}
                     currentItemUuid={currentItemUuid}
-                    currentRoute={paperKey} />
-                <Toolbar className={classes.footer}>
+                    currentRoute={paperKey} /></Grid>
+                <Grid item xs><Toolbar className={classes.footer}>
                     <Grid container justify="flex-end">
                         {fetchMode === DataTableFetchMode.PAGINATED ? <TablePagination
                             count={itemsAvailable}
@@ -143,7 +167,8 @@ export const DataExplorer = withStyles(styles)(
                                 onClick={this.loadMore}
                             >Load more</Button>}
                     </Grid>
-                </Toolbar>
+                </Toolbar></Grid>
+                </Grid>
             </Paper>;
         }
 
index 0c84f642fd1cc2733b9a6d0cec03ab72b21ca669..de52d365030dfdc7a48a47bb2f6f52cd31fade51 100644 (file)
@@ -39,13 +39,11 @@ type CssRules = "tableBody" | "root" | "content" | "noItemsInfo" | 'tableCell' |
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
-        overflowX: 'auto',
-        overflowY: 'auto',
-        height: 'calc(100vh - 280px)',
+        width: '100%',
     },
     content: {
         display: 'inline-block',
-        width: '100%'
+        width: '100%',
     },
     tableBody: {
         background: theme.palette.background.paper
index 617529cdc07da56bdf9579c9b63476c276dbfaff..54d5b5db2b9513a34579d1d5465a89587c3fb8eb 100644 (file)
@@ -123,6 +123,17 @@ export const FileUpload = withStyles(styles)(
             if (!disabled) {
                 onDelete(file);
             }
+
+            let interval = setInterval(() => {
+                const key = Object.keys((window as any).cancelTokens).find(key => key.indexOf(file.file.name) > -1);
+
+                if (key) {
+                    clearInterval(interval);
+                    (window as any).cancelTokens[key]();
+                    delete (window as any).cancelTokens[key];
+                }
+            }, 100);
+
         }
         render() {
             const { classes, onDrop, disabled, files } = this.props;
@@ -140,6 +151,7 @@ export const FileUpload = withStyles(styles)(
                             inputs[0].focus();
                         }
                     }}
+                    data-cy="drag-and-drop"
                     disabled={disabled}
                     inputProps={{
                         onFocus: () => {
index 19145cea34ae12b62813742e8c20acfb419b5f13..0fc799dee92b62eb0ab58a6ce100403dc7477193 100644 (file)
@@ -42,7 +42,9 @@ interface DialogProjectDataProps {
     dialogTitle: string;
     formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
     submitLabel?: string;
+    cancelCallback?: Function;
     enableWhenPristine?: boolean;
+    doNotDisableCancel?: boolean;
 }
 
 type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>;
@@ -65,10 +67,18 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
             <DialogActions className={props.classes.dialogActions}>
                 <Button
                     data-cy='form-cancel-btn'
-                    onClick={props.closeDialog}
+                    onClick={() => {
+                        props.closeDialog();
+
+                        if (props.cancelCallback) {
+                            props.cancelCallback();
+                            props.reset();
+                            props.initialize({});
+                        }
+                    }}
                     className={props.classes.button}
                     color="primary"
-                    disabled={props.submitting}>
+                    disabled={props.doNotDisableCancel ? false : props.submitting}>
                     {props.cancelLabel || 'Cancel'}
                 </Button>
                 <Button
index 4543d8d9a688cb148aff742d8c75e9736970a168..15a9f02d7339ab9c05411135d00d8190f06a0a92 100644 (file)
@@ -59,6 +59,8 @@ import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
 import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
+import Visibility from '@material-ui/icons/Visibility';
+import VisibilityOff from '@material-ui/icons/VisibilityOff';
 import VpnKey from '@material-ui/icons/VpnKey';
 import LinkOutlined from '@material-ui/icons/LinkOutlined';
 import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
@@ -67,6 +69,7 @@ import Computer from '@material-ui/icons/Computer';
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
 import { faPencilAlt, faSlash, faUsers } from '@fortawesome/free-solid-svg-icons';
+import { CropFreeSharp } from '@material-ui/icons';
 library.add(
     faPencilAlt,
     faSlash,
@@ -120,10 +123,12 @@ export const FileIcon: IconType = (props) => <DescriptionIcon {...props} />;
 export const HelpIcon: IconType = (props) => <Help {...props} />;
 export const HelpOutlineIcon: IconType = (props) => <HelpOutline {...props} />;
 export const ImportContactsIcon: IconType = (props) => <ImportContacts {...props} />;
+export const InfoIcon: IconType = (props) => <Info {...props} />;
 export const InputIcon: IconType = (props) => <InsertDriveFile {...props} />;
 export const KeyIcon: IconType = (props) => <VpnKey {...props} />;
 export const LogIcon: IconType = (props) => <SettingsEthernet {...props} />;
 export const MailIcon: IconType = (props) => <Mail {...props} />;
+export const MaximizeIcon: IconType = (props) => <CropFreeSharp {...props} />;
 export const MoreOptionsIcon: IconType = (props) => <MoreVert {...props} />;
 export const MoveToIcon: IconType = (props) => <Input {...props} />;
 export const NewProjectIcon: IconType = (props) => <CreateNewFolder {...props} />;
@@ -152,6 +157,8 @@ export const SidePanelRightArrowIcon: IconType = (props) => <PlayArrow {...props
 export const TrashIcon: IconType = (props) => <Delete {...props} />;
 export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
+export const VisibleIcon: IconType = (props) => <Visibility {...props} />;
+export const InvisibleIcon: IconType = (props) => <VisibilityOff {...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} />;
diff --git a/src/components/multi-panel-view/multi-panel-view.test.tsx b/src/components/multi-panel-view/multi-panel-view.test.tsx
new file mode 100644 (file)
index 0000000..6cf13d7
--- /dev/null
@@ -0,0 +1,87 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { MPVContainer } from './multi-panel-view';
+import { Button } from "@material-ui/core";
+
+configure({ adapter: new Adapter() });
+
+const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, children, ...rest}) =>
+    <div {...rest}>{children}</div>;
+
+describe('<MPVContainer />', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            classes: {},
+        };
+    });
+
+    it('should show default panel buttons for every child', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+            <PanelMock key={2}>This is another panel</PanelMock>,
+        ];
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).first().html()).toContain('Panel 1');
+        expect(wrapper.html()).toContain('This is one panel');
+        expect(wrapper.find(Button).last().html()).toContain('Panel 2');
+        expect(wrapper.html()).toContain('This is another panel');
+    });
+
+    it('should show panel when clicking on its button', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'Initially invisible Panel', visible: false},
+        ]
+
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+
+        // Initial state: panel not visible
+        expect(wrapper.html()).not.toContain('This is one panel');
+        expect(wrapper.html()).toContain('All panels are hidden');
+
+        // Panel visible when clicking on its button
+        wrapper.find(Button).simulate('click');
+        expect(wrapper.html()).toContain('This is one panel');
+        expect(wrapper.html()).not.toContain('All panels are hidden');
+    });
+
+    it('should show custom panel buttons when config provided', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+            <PanelMock key={2}>This is another panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'First Panel'},
+        ]
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).first().html()).toContain('First Panel');
+        expect(wrapper.html()).toContain('This is one panel');
+        // Second panel received the default button naming and hidden status by default
+        expect(wrapper.find(Button).last().html()).toContain('Panel 2');
+        expect(wrapper.html()).not.toContain('This is another panel');
+        wrapper.find(Button).last().simulate('click');
+        expect(wrapper.html()).toContain('This is another panel');
+    });
+
+    it('should set panel hidden when requested', () => {
+        const childs = [
+            <PanelMock key={1}>This is one panel</PanelMock>,
+        ];
+        props.panelStates = [
+            {name: 'First Panel', visible: false},
+        ]
+        const wrapper = mount(<MPVContainer {...props}>{[...childs]}</MPVContainer>);
+        expect(wrapper.find(Button).html()).toContain('First Panel');
+        expect(wrapper.html()).not.toContain('This is one panel');
+        expect(wrapper.html()).toContain('All panels are hidden');
+    });
+});
\ No newline at end of file
diff --git a/src/components/multi-panel-view/multi-panel-view.tsx b/src/components/multi-panel-view/multi-panel-view.tsx
new file mode 100644 (file)
index 0000000..dbb3792
--- /dev/null
@@ -0,0 +1,198 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { MutableRefObject, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
+import {
+    Button,
+    Grid,
+    Paper,
+    StyleRulesCallback,
+    Tooltip,
+    withStyles,
+    WithStyles
+} from "@material-ui/core";
+import { GridProps } from '@material-ui/core/Grid';
+import { isArray } from 'lodash';
+import { DefaultView } from 'components/default-view/default-view';
+import { InfoIcon, InvisibleIcon, VisibleIcon } from 'components/icon/icon';
+import { ReactNodeArray } from 'prop-types';
+import classNames from 'classnames';
+
+type CssRules = 'button' | 'buttonIcon' | 'content';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        padding: '2px 5px',
+        marginRight: '5px',
+    },
+    buttonIcon: {
+        boxShadow: 'none',
+        padding: '2px 0px 2px 5px',
+        fontSize: '1rem'
+    },
+    content: {
+        overflow: 'auto',
+    },
+});
+
+interface MPVHideablePanelDataProps {
+    name: string;
+    visible: boolean;
+    maximized: boolean;
+    illuminated: boolean;
+    children: ReactNode;
+    panelRef?: MutableRefObject<any>;
+}
+
+interface MPVHideablePanelActionProps {
+    doHidePanel: () => void;
+    doMaximizePanel: () => void;
+}
+
+type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps;
+
+const MPVHideablePanel = ({doHidePanel, doMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) =>
+    visible
+    ? <>
+        {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
+    </>
+    : null;
+
+interface MPVPanelDataProps {
+    panelName?: string;
+    panelMaximized?: boolean;
+    panelIlluminated?: boolean;
+    panelRef?: MutableRefObject<any>;
+}
+
+interface MPVPanelActionProps {
+    doHidePanel?: () => void;
+    doMaximizePanel?: () => void;
+}
+
+// Props received by panel implementors
+export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
+
+type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps;
+
+// Grid item compatible component for layout and MPV props passing
+export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName, panelMaximized, panelIlluminated, panelRef, ...props}: MPVPanelContentProps) => {
+    useEffect(() => {
+        if (panelRef && panelRef.current) {
+            panelRef.current.scrollIntoView({behavior: 'smooth'});
+        }
+    }, [panelRef]);
+
+    return <Grid item {...props}>
+        <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
+        <Paper style={{height: '100%'}} elevation={panelIlluminated ? 8 : 0}>
+            {React.cloneElement(props.children, { doHidePanel, doMaximizePanel, panelName, panelMaximized })}
+        </Paper>
+    </Grid>;
+}
+
+export interface MPVPanelState {
+    name: string;
+    visible?: boolean;
+}
+interface MPVContainerDataProps {
+    panelStates?: MPVPanelState[];
+}
+type MPVContainerProps = MPVContainerDataProps & GridProps;
+
+// Grid container compatible component that also handles panel toggling.
+const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVContainerProps & WithStyles<CssRules>) => {
+    if (children === undefined || children === null || children === {}) {
+        children = [];
+    } else if (!isArray(children)) {
+        children = [children];
+    }
+    const visibility = (children as ReactNodeArray).map((_, idx) =>
+        !!!panelStates || // if panelStates wasn't passed, default to all visible panels
+            (panelStates[idx] &&
+                (panelStates[idx].visible || panelStates[idx].visible === undefined)));
+    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
+    const [brightenedPanel, setBrightenedPanel] = useState<number>(-1);
+    const panelRef = useRef<any>(null);
+
+    let panels: JSX.Element[] = [];
+    let toggles: JSX.Element[] = [];
+
+    if (isArray(children)) {
+        for (let idx = 0; idx < children.length; idx++) {
+            const showFn = (idx: number) => () => {
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx),
+                    true,
+                    ...panelVisibility.slice(idx+1)
+                ]);
+            };
+            const hideFn = (idx: number) => () => {
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx),
+                    false,
+                    ...panelVisibility.slice(idx+1)
+                ])
+            };
+            const maximizeFn = (idx: number) => () => {
+                // Maximize X == hide all but X
+                setPanelVisibility([
+                    ...panelVisibility.slice(0, idx).map(() => false),
+                    true,
+                    ...panelVisibility.slice(idx+1).map(() => false),
+                ])
+            };
+            const toggleIcon = panelVisibility[idx]
+                ? <VisibleIcon className={classNames(classes.buttonIcon)} />
+                : <InvisibleIcon className={classNames(classes.buttonIcon)}/>
+            const panelName = panelStates === undefined
+                ? `Panel ${idx+1}`
+                : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`;
+            const toggleVariant = "outlined";
+            const toggleTooltip = panelVisibility[idx]
+                ? ''
+                :`Show ${panelName} panel`;
+            const panelIsMaximized = panelVisibility[idx] &&
+                panelVisibility.filter(e => e).length === 1;
+
+            toggles = [
+                ...toggles,
+                <Tooltip title={toggleTooltip} disableFocusListener>
+                    <Button variant={toggleVariant} size="small" color="primary"
+                        className={classNames(classes.button)}
+                        onMouseEnter={() => setBrightenedPanel(idx)}
+                        onMouseLeave={() => setBrightenedPanel(-1)}
+                        onClick={showFn(idx)}>
+                            {panelName}
+                            {toggleIcon}
+                    </Button>
+                </Tooltip>
+            ];
+
+            const aPanel =
+                <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
+                    panelRef={(idx === brightenedPanel) ? panelRef : undefined}
+                    maximized={panelIsMaximized} illuminated={idx === brightenedPanel}
+                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)}>
+                    {children[idx]}
+                </MPVHideablePanel>;
+            panels = [...panels, aPanel];
+        };
+    };
+
+    return <Grid container {...props}>
+        <Grid container item direction="row">
+            { toggles.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
+        </Grid>
+        <Grid container item {...props} xs className={classes.content}>
+            { panelVisibility.includes(true)
+                ? panels
+                : <Grid container item alignItems='center' justify='center'>
+                    <DefaultView messages={["All panels are hidden.", "Click on the buttons above to show them."]} icon={InfoIcon} />
+                </Grid> }
+        </Grid>
+    </Grid>;
+};
+
+export const MPVContainer = withStyles(styles)(MPVContainerComponent);
\ No newline at end of file
diff --git a/src/components/panel-default-view/panel-default-view.tsx b/src/components/panel-default-view/panel-default-view.tsx
deleted file mode 100644 (file)
index c364bb7..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { DefaultViewDataProps, DefaultView } from 'components/default-view/default-view';
-
-type CssRules = 'classRoot' | 'classIcon' | 'classMessage';
-
-const styles: StyleRulesCallback<CssRules> = () => ({
-    classRoot: {
-        position: 'absolute',
-        width: '80%',
-        left: '50%',
-        top: '50%',
-        transform: 'translate(-50%, -50%)'
-    },
-    classMessage: {
-        fontSize: '1.75rem',
-    },
-    classIcon: {
-        fontSize: '6rem'
-    }
-});
-
-type PanelDefaultViewProps = Pick<DefaultViewDataProps, 'icon' | 'messages'> & WithStyles<CssRules>;
-
-export const PanelDefaultView = withStyles(styles)(
-    ({ classes, ...props }: PanelDefaultViewProps) =>
-        <DefaultView {...classes} {...props} />);
index 90c52b7639e2d534d594bb1a6252c22b782109ff..c57d3608b06b470e260ed2ceabf76875290f3019 100644 (file)
@@ -21,20 +21,20 @@ describe("<SearchInput />", () => {
 
     describe("on submit", () => {
         it("calls onSearch with initial value passed via props", () => {
-            const searchInput = mount(<SearchInput value="initial value" onSearch={onSearch} />);
+            const searchInput = mount(<SearchInput selfClearProp="" value="initial value" onSearch={onSearch} />);
             searchInput.find("form").simulate("submit");
             expect(onSearch).toBeCalledWith("initial value");
         });
 
         it("calls onSearch with current value", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             searchInput.find("form").simulate("submit");
             expect(onSearch).toBeCalledWith("current value");
         });
 
         it("calls onSearch with new value passed via props", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             searchInput.setProps({value: "new value"});
             searchInput.find("form").simulate("submit");
@@ -42,7 +42,7 @@ describe("<SearchInput />", () => {
         });
 
         it("cancels timeout set on input value change", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000} />);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000} />);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             searchInput.find("form").simulate("submit");
             jest.runTimersToTime(1000);
@@ -54,7 +54,7 @@ describe("<SearchInput />", () => {
 
     describe("on input value change", () => {
         it("calls onSearch after default timeout", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} />);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} />);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             expect(onSearch).not.toBeCalled();
             jest.runTimersToTime(DEFAULT_SEARCH_DEBOUNCE);
@@ -62,7 +62,7 @@ describe("<SearchInput />", () => {
         });
 
         it("calls onSearch after the time specified in props has passed", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={2000}/>);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={2000}/>);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             jest.runTimersToTime(1000);
             expect(onSearch).not.toBeCalled();
@@ -71,7 +71,7 @@ describe("<SearchInput />", () => {
         });
 
         it("calls onSearch only once after no change happened during the specified time", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000}/>);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             jest.runTimersToTime(500);
             searchInput.find("input").simulate("change", { target: { value: "changed value" } });
@@ -80,7 +80,7 @@ describe("<SearchInput />", () => {
         });
 
         it("calls onSearch again after the specified time has passed since previous call", () => {
-            const searchInput = mount(<SearchInput value="" onSearch={onSearch} debounce={1000}/>);
+            const searchInput = mount(<SearchInput selfClearProp="" value="" onSearch={onSearch} debounce={1000}/>);
             searchInput.find("input").simulate("change", { target: { value: "current value" } });
             jest.runTimersToTime(500);
             searchInput.find("input").simulate("change", { target: { value: "intermediate value" } });
@@ -95,4 +95,14 @@ describe("<SearchInput />", () => {
 
     });
 
+    describe("on input target change", () => {
+        it("clears the input value on selfClearProp change", () => {
+            const searchInput = mount(<SearchInput selfClearProp="abc" value="123" onSearch={onSearch} debounce={1000}/>);
+            searchInput.setProps({ selfClearProp: 'aaa' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(1);
+        });
+    });
+
 });
index 5d5a9a226009c5cea5e7c2893ab5a9a85724f067..50338f401c9387b680ef417a715e2130d932b265 100644 (file)
@@ -35,6 +35,7 @@ const styles: StyleRulesCallback<CssRules> = theme => {
 interface SearchInputDataProps {
     value: string;
     label?: string;
+    selfClearProp: string;
 }
 
 interface SearchInputActionProps {
@@ -47,6 +48,7 @@ type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyl
 interface SearchInputState {
     value: string;
     label: string;
+    selfClearProp: string;
 }
 
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
@@ -55,7 +57,8 @@ export const SearchInput = withStyles(styles)(
     class extends React.Component<SearchInputProps> {
         state: SearchInputState = {
             value: "",
-            label: ""
+            label: "",
+            selfClearProp: ""
         };
 
         timeout: number;
@@ -66,6 +69,7 @@ export const SearchInput = withStyles(styles)(
                     <InputLabel>{this.state.label}</InputLabel>
                     <Input
                         type="text"
+                        data-cy="search-input"
                         value={this.state.value}
                         onChange={this.handleChange}
                         endAdornment={
@@ -93,6 +97,10 @@ export const SearchInput = withStyles(styles)(
             if (nextProps.value !== this.props.value) {
                 this.setState({ value: nextProps.value });
             }
+            if (this.state.value !== '' && nextProps.selfClearProp && nextProps.selfClearProp !== this.state.selfClearProp) {
+                this.props.onSearch('');
+                this.setState({ selfClearProp: nextProps.selfClearProp });
+            }
         }
 
         componentWillUnmount() {
index c0aa85f1d3ab3e8df9912b993c978ef67d8c5834..b759fd1a46d9cb622e9567196887667d71e6a9ab 100644 (file)
@@ -30,6 +30,41 @@ describe('collection-service', () => {
         collectionService.update = jest.fn();
     });
 
+    describe('get', () => {
+        it('should make a list request with uuid filtering', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await collectionService.get(uuid);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                '/collections', {
+                    params: {
+                        filters: `[["uuid","=","zzzzz-4zz18-0123456789abcde"]]`,
+                        include_old_versions: true,
+                    },
+                }
+            );
+        });
+
+        it('should be able to request specific fields', async () => {
+            serverApi.get = jest.fn(() => Promise.resolve(
+                { data: { items: [{}] } }
+            ));
+            const uuid = 'zzzzz-4zz18-0123456789abcde'
+            await collectionService.get(uuid, undefined, ['manifestText']);
+            expect(serverApi.get).toHaveBeenCalledWith(
+                '/collections', {
+                    params: {
+                        filters: `[["uuid","=","zzzzz-4zz18-0123456789abcde"]]`,
+                        include_old_versions: true,
+                        select: `["manifest_text"]`
+                    },
+                }
+            );
+        });
+    });
+
     describe('update', () => {
         it('should call put selecting updated fields + others', async () => {
             serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
index 0c3cda3bcb8abcc2ae779cf83156cdd54d7e3373..b6272650debf07034f2c3f336880d94e2814a42a 100644 (file)
@@ -11,6 +11,8 @@ import { extractFilesData } from "./collection-service-files-response";
 import { TrashableResourceService } from "services/common-service/trashable-resource-service";
 import { ApiActions } from "services/api/api-actions";
 import { customEncodeURI } from "common/url";
+import { FilterBuilder } from "services/api/filter-builder";
+import { ListArguments } from "services/common-service/common-service";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
@@ -28,6 +30,18 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         ]);
     }
 
+    async get(uuid: string, showErrors?: boolean, select?: string[]) {
+        super.validateUuid(uuid);
+        // We use a filtered list request to avoid getting the manifest text
+        const filters = new FilterBuilder().addEqual('uuid', uuid).getFilters();
+        const listArgs: ListArguments = {filters, includeOldVersions: true};
+        if (select) {
+            listArgs.select = select;
+        }
+        const lst = await super.list(listArgs, showErrors);
+        return lst.items[0];
+    }
+
     create(data?: Partial<CollectionResource>) {
         return super.create({ ...data, preserveVersion: true });
     }
@@ -108,7 +122,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
             },
             onUploadProgress: (e: ProgressEvent) => {
                 onProgress(fileId, e.loaded, e.total, Date.now());
-            }
+            },
         };
         return this.webdavClient.upload(fileURL, [file], requestConfig);
     }
index 82777342b3b9931412ecacdac8290d240cf7fdc4..f66fad74b701c07d079d56bf087375a049e18a1d 100644 (file)
@@ -68,7 +68,7 @@ export class CommonService<T> {
             }
         }
 
-    private validateUuid(uuid: string) {
+    protected validateUuid(uuid: string) {
         if (uuid === "") {
             throw new Error('UUID cannot be empty string');
         }
@@ -124,18 +124,21 @@ export class CommonService<T> {
         );
     }
 
-    list(args: ListArguments = {}): Promise<ListResults<T>> {
-        const { filters, order, ...other } = args;
+    list(args: ListArguments = {}, showErrors?: boolean): Promise<ListResults<T>> {
+        const { filters, select, ...other } = args;
         const params = {
             ...CommonService.mapKeys(snakeCase)(other),
             filters: filters ? `[${filters}]` : undefined,
-            order: order ? order : undefined
+            select: select
+                ? `[${select.map(snakeCase).map(s => `"${s}"`).join(', ')}]`
+                : undefined
         };
 
         if (QueryString.stringify(params).length <= 1500) {
             return CommonService.defaultResponse(
                 this.serverApi.get(`/${this.resourceType}`, { params }),
-                this.actions
+                this.actions,
+                showErrors
             );
         } else {
             // Using the POST special case to avoid URI length 414 errors.
@@ -152,7 +155,8 @@ export class CommonService<T> {
                         _method: 'GET'
                     }
                 }),
-                this.actions
+                this.actions,
+                showErrors
             );
         }
     }
index 0f8bf3cb5e010b288fe912971d5d03109b96d6ba..25d90195d7028a079423ce5ed2eb73b5d9150d80 100644 (file)
@@ -411,7 +411,7 @@ const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => {
 
 const collectionApiResponse = (apiResponse: CollectionResource) => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, portableDataHash, replicationDesired,
-        replicationConfirmedAt, replicationConfirmed, manifestText, deleteAt, trashAt, isTrashed, storageClassesDesired,
+        replicationConfirmedAt, replicationConfirmed, deleteAt, trashAt, isTrashed, storageClassesDesired,
         storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion, fileCount, fileSizeTotal } = apiResponse;
     const response = `
 "uuid": "${uuid}",
@@ -424,7 +424,6 @@ const collectionApiResponse = (apiResponse: CollectionResource) => {
 "replication_desired": ${stringify(replicationDesired)},
 "replication_confirmed_at": ${stringify(replicationConfirmedAt)},
 "replication_confirmed": ${stringify(replicationConfirmed)},
-"manifest_text": ${stringify(manifestText)},
 "name": ${stringify(name)},
 "description": ${stringify(description)},
 "properties": ${stringifyObject(properties)},
index 9d812783b8379f2b1701795d099b0129a2fa210d..eb9c64fdcc576a6cdb7a44b5af051b99b85977cb 100644 (file)
@@ -12,6 +12,8 @@ import { getCommonResourceServiceError, CommonResourceServiceError } from 'servi
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
 
 export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
 
@@ -27,9 +29,15 @@ export const openCollectionCopyDialog = (resource: { name: string, uuid: string
 export const copyCollection = (resource: CopyFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(COLLECTION_COPY_FORM_NAME));
+        let collection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
-            const collection = await services.collectionService.get(resource.uuid);
-            const newCollection = await services.collectionService.create({ ...collection, ownerUuid: resource.ownerUuid, name: resource.name });
+            if (!collection) {
+                collection = await services.collectionService.get(resource.uuid);
+            }
+            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ['manifestText']);
+            collection.manifestText = collManifestText.manifestText;
+            const {href, ...collectionRecord} = collection;
+            const newCollection = await services.collectionService.create({ ...collectionRecord, ownerUuid: resource.ownerUuid, name: resource.name });
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
             return newCollection;
         } catch (e) {
index d056b6e5f67ce2ad8cb1196572b1b8789094711f..929f1612f7b8c3baa6ded53a46091e811cdc2a38 100644 (file)
@@ -14,6 +14,8 @@ import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
 
 export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
 
@@ -28,13 +30,17 @@ export const openMoveCollectionDialog = (resource: { name: string, uuid: string
 export const moveCollection = (resource: MoveToFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
+        let cachedCollection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
             dispatch(progressIndicatorActions.START_WORKING(COLLECTION_MOVE_FORM_NAME));
+            if (!cachedCollection) {
+                cachedCollection = await services.collectionService.get(resource.uuid);
+            }
             const collection = await services.collectionService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
-            return collection;
+            return {...cachedCollection, ...collection};
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
index 49900f2c288d80d739f5d11f131f9cd17a88dba2..9f478d74b71cd5d1bd292167e4626f226ba14dbe 100644 (file)
@@ -52,13 +52,13 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec
         if (currentCollection) {
             try {
                 dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
-                const collection = await services.collectionService.get(currentCollection.uuid);
+                const collectionManifestText = await services.collectionService.get(currentCollection.uuid, undefined, ['manifestText']);
                 const collectionCopy = {
                     name,
                     description,
                     ownerUuid: projectUuid,
                     uuid: undefined,
-                    manifestText: collection.manifestText,
+                    manifestText: collectionManifestText.manifestText,
                 };
                 const newCollection = await services.collectionService.create(collectionCopy);
                 const copiedFiles = await services.collectionService.files(newCollection.uuid);
@@ -67,7 +67,7 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec
                     return !paths.find(path => path.indexOf(file.replace(newCollection.uuid, '')) > -1);
                 });
                 await services.collectionService.deleteFiles(
-                    '',
+                    newCollection.uuid,
                     filesToDelete
                 );
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
@@ -114,7 +114,7 @@ export const copyCollectionPartialToSelectedCollection = ({ collectionUuid }: Co
         const currentCollection = state.collectionPanel.item;
 
         if (currentCollection && !currentCollection.manifestText) {
-            const fetchedCurrentCollection = await services.collectionService.get(currentCollection.uuid);
+            const fetchedCurrentCollection = await services.collectionService.get(currentCollection.uuid, undefined, ['manifestText']);
             currentCollection.manifestText = fetchedCurrentCollection.manifestText;
             currentCollection.unsignedManifestText = fetchedCurrentCollection.unsignedManifestText;
         }
@@ -135,7 +135,7 @@ export const copyCollectionPartialToSelectedCollection = ({ collectionUuid }: Co
                 });
                 const diffPathToRemove = difference(paths, pathsToRemove);
                 await services.collectionService.deleteFiles(selectedCollection.uuid, pathsToRemove.map(path => path.replace(currentCollection.uuid, collectionUuid)));
-                const collectionWithDeletedFiles = await services.collectionService.get(collectionUuid);
+                const collectionWithDeletedFiles = await services.collectionService.get(collectionUuid, undefined, ['uuid', 'manifestText']);
                 await services.collectionService.update(collectionUuid, { manifestText: `${collectionWithDeletedFiles.manifestText}${(currentCollection.manifestText ? currentCollection.manifestText : currentCollection.unsignedManifestText) || ''}` });
                 await services.collectionService.deleteFiles(collectionWithDeletedFiles.uuid, diffPathToRemove.map(path => path.replace(currentCollection.uuid, collectionUuid)));
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
index 8f85ea18a6c4b081238801d5394788f950c81c9e..0ca681b98ed2fcc9d8550165b477231f58fa0287 100644 (file)
@@ -14,12 +14,14 @@ import { progressIndicatorActions } from "store/progress-indicator/progress-indi
 import { collectionPanelFilesAction } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
 import { createTree } from 'models/tree';
 import { loadCollectionPanel } from '../collection-panel/collection-panel-action';
+import * as WorkbenchActions from 'store/workbench/workbench-actions';
 
 export const uploadCollectionFiles = (collectionUuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch(fileUploaderActions.START_UPLOAD());
         const files = getState().fileUploader.map(file => file.file);
         await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch));
+        dispatch(WorkbenchActions.loadCollection(collectionUuid));
         dispatch(fileUploaderActions.CLEAR_UPLOAD());
     };
 
index c0a58432d7f6be7c4e5412ec5f5f72658d190c13..7d2511eda24d90368df29716c94157e090aeb10a 100644 (file)
@@ -9,6 +9,8 @@ import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
 import { resourcesActions } from "../resources/resources-actions";
 import { navigateTo } from "../navigation/navigation-action";
 import { dialogActions } from "../dialog/dialog-actions";
+import { getResource } from "store/resources/resources";
+import { CollectionResource } from "models/collection";
 
 export const COLLECTION_RESTORE_VERSION_DIALOG = 'collectionRestoreVersionDialog';
 
@@ -28,9 +30,15 @@ export const openRestoreCollectionVersionDialog = (uuid: string) =>
 export const restoreVersion = (resourceUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         try {
-            // Request que entire record because stored old versions usually
-            // don't include the manifest_text field.
-            const oldVersion = await services.collectionService.get(resourceUuid);
+            // Request the manifest text because stored old versions usually
+            // don't include them.
+            let oldVersion = getResource<CollectionResource>(resourceUuid)(getState().resources);
+            if (!oldVersion) {
+                oldVersion = await services.collectionService.get(resourceUuid);
+            }
+            const oldVersionManifest = await services.collectionService.get(resourceUuid, undefined, ['manifestText']);
+            oldVersion.manifestText = oldVersionManifest.manifestText;
+
             const { uuid, version, ...rest} = oldVersion;
             const headVersion = await services.collectionService.update(
                 oldVersion.currentVersionUuid,
index 8436c485f9ba098efbe0fa880c44c8df7f0c4cb9..a397bbd825f23d7f936fa63470b2a091f27a2642 100644 (file)
@@ -24,6 +24,7 @@ export const fileUploaderActions = unionize({
     SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(),
     START_UPLOAD: ofType(),
     DELETE_UPLOAD_FILE: ofType<UploadFile>(),
+    CANCEL_FILES_UPLOAD: ofType(),
 });
 
 export type FileUploaderAction = UnionOf<typeof fileUploaderActions>;
index c1f9c6810958467629ecdddb0d74fb00035f35fd..4218fbee61a9df1689fa3b69a36d40d328e2d2a2 100644 (file)
@@ -43,6 +43,21 @@ export const fileUploaderReducer = (state: UploaderState = initialState, action:
 
             return updatedState;
         },
+        CANCEL_FILES_UPLOAD: () => {
+            state.forEach((file) => {
+                let interval = setInterval(() => {
+                    const key = Object.keys((window as any).cancelTokens).find(key => key.indexOf(file.file.name) > -1);
+    
+                    if (key) {
+                        clearInterval(interval);
+                        (window as any).cancelTokens[key]();
+                        delete (window as any).cancelTokens[key];
+                    }
+                }, 100);
+            });
+
+            return [];
+        },
         START_UPLOAD: () => {
             const startTime = Date.now();
             return state.map(f => ({ ...f, startTime, prevTime: startTime }));
index deaaab6a85515b5e4464f361c958ab5894ec06b1..87b50bd2f97ff89f1a7d057e580aa3093a13bf80 100644 (file)
@@ -1,9 +1,10 @@
-import { RootState } from '../store';
-import { matchProcessLogRoute } from 'routes/routes';
 // Copyright (C) The Arvados Authors. All rights reserved.
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { RootState } from '../store';
+import { matchProcessLogRoute, matchProcessRoute } from 'routes/routes';
+
 export interface ProcessLogsPanel {
     filters: string[];
     selectedFilter: string;
@@ -20,6 +21,6 @@ export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel)
 
 export const getProcessLogsPanelCurrentUuid = ({ router }: RootState) => {
     const pathname = router.location ? router.location.pathname : '';
-    const match = matchProcessLogRoute(pathname);
+    const match = matchProcessLogRoute(pathname) || matchProcessRoute(pathname);
     return match ? match.params.id : undefined;
 };
index 3aeec4c09c08ed854612ebdc3aeb55c248f406f9..59a5f368af40db783e489bd79d8543d77c66f7d9 100644 (file)
@@ -26,12 +26,13 @@ export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [[
             dispatch<any>(openCollectionPartialCopyDialog());
         }
     },
-    {
-        name: "Copy selected into the collection",
-        execute: dispatch => {
-            dispatch<any>(openCollectionPartialCopyToSelectedCollectionDialog());
-        }
-    }
+    // Disabled while addressing #18587
+    // {
+    //     name: "Copy selected into the collection",
+    //     execute: dispatch => {
+    //         dispatch<any>(openCollectionPartialCopyToSelectedCollectionDialog());
+    //     }
+    // }
 ]];
 
 export const collectionFilesActionSet: ContextMenuActionSet = readOnlyCollectionFilesActionSet.concat([[
index c4b374b9c130a2b96fac93b4ad7fe5e34822f640..d9c991f5858ea4c1ca3a50f1903716d044dfd350 100644 (file)
@@ -5,12 +5,8 @@
 import React from 'react';
 import { ProcessIcon } from 'components/icon/icon';
 import { ProcessResource } from 'models/process';
-import { formatDate } from 'common/formatters';
-import { ResourceKind } from 'models/resource';
-import { resourceLabel } from 'common/labels';
 import { DetailsData } from "./details-data";
-import { DetailsAttribute } from "components/details-attribute/details-attribute";
-import { ResourceOwnerWithName } from '../data-explorer/renderers';
+import { ProcessDetailsAttributes } from 'views/process-panel/process-details-attributes';
 
 export class ProcessDetails extends DetailsData<ProcessResource> {
 
@@ -19,25 +15,6 @@ export class ProcessDetails extends DetailsData<ProcessResource> {
     }
 
     getDetails() {
-        return <div>
-            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
-            <DetailsAttribute label='Owner' linkToUuid={this.item.ownerUuid} value={this.item.ownerUuid}
-                uuidEnhancer={(uuid: string) => <ResourceOwnerWithName uuid={uuid} />} />
-
-            <DetailsAttribute label='Status' value={this.item.state} />
-            <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
-
-            <DetailsAttribute label='Started at' value={formatDate(this.item.createdAt)} />
-            <DetailsAttribute label='Finished at' value={formatDate(this.item.expiresAt)} />
-
-            <DetailsAttribute label='Outputs' value={this.item.outputPath} />
-            <DetailsAttribute label='UUID' linkToUuid={this.item.uuid} value={this.item.uuid} />
-            <DetailsAttribute label='Container UUID' value={this.item.containerUuid} />
-
-            <DetailsAttribute label='Priority' value={this.item.priority} />
-            <DetailsAttribute label='Runtime Constraints' value={JSON.stringify(this.item.runtimeConstraints)} />
-
-            <DetailsAttribute label='Docker Image locator' linkToUuid={this.item.containerImage} value={this.item.containerImage} />
-        </div>;
+        return <ProcessDetailsAttributes item={this.item} />;
     }
 }
index 2f662bfae06ffa20982f8f23a409ef6bf8dc9ac5..f65bdabfeb935ef79a93609a5beed7f3988a66d6 100644 (file)
@@ -10,16 +10,30 @@ import { FormDialog } from 'components/form-dialog/form-dialog';
 import { require } from 'validators/require';
 import { FileUploaderField } from 'views-components/file-uploader/file-uploader';
 import { WarningCollection } from 'components/warning-collection/warning-collection';
+import { fileUploaderActions } from 'store/file-uploader/file-uploader-actions';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 
 type DialogCollectionFilesUploadProps = WithDialogProps<{}> & InjectedFormProps<CollectionCreateFormDialogData>;
 
-export const DialogCollectionFilesUpload = (props: DialogCollectionFilesUploadProps) =>
-    <FormDialog
+export const DialogCollectionFilesUpload = (props: DialogCollectionFilesUploadProps) => {
+
+    return <FormDialog
         dialogTitle='Upload data'
         formFields={UploadCollectionFilesFields}
         submitLabel='Upload data'
+        doNotDisableCancel
+        cancelCallback={() => {
+            const { submitting, dispatch } = (props as any);
+
+            if (submitting) {
+                dispatch(progressIndicatorActions.STOP_WORKING('uploadCollectionFilesDialog'));
+                dispatch(fileUploaderActions.CANCEL_FILES_UPLOAD());
+                dispatch(fileUploaderActions.CLEAR_UPLOAD());
+            }
+        }}
         {...props}
     />;
+}
 
 const UploadCollectionFilesFields = () => <>
     <Field
index 82e400f78a241a114f72999f3e5fae735a7c0fc9..cde286c450c4764be139b288c65ab3ab55f9d4e2 100644 (file)
@@ -30,7 +30,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { onDrop }: FileUploaderProps):
             onDrop(files);
         }
     },
-    onDelete: file => dispatch(fileUploaderActions.DELETE_UPLOAD_FILE(file)),
+    onDelete: file => dispatch(fileUploaderActions.DELETE_UPLOAD_FILE(file))
 });
 
 export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpload);
@@ -38,5 +38,5 @@ export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpl
 export const FileUploaderField = (props: WrappedFieldProps & { label?: string }) =>
     <div>
         <Typography variant='caption'>{props.label}</Typography>
-        <FileUploader disabled={props.meta.submitting} onDrop={props.input.onChange} />
+        <FileUploader disabled={false} onDrop={props.input.onChange} />
     </div>;
index 10ae1790da29cff215bb48de177f08bce707ad08..480150cb45daec040f12d05d27f3ccc3043cfdf6 100644 (file)
@@ -60,7 +60,7 @@ export const MainContentBar =
         buttonVisible: isButtonVisible(state),
         projectUuid: state.detailsPanel.resourceUuid,
     }), (dispatch) => ({
-            onDetailsPanelToggle: toggleDetailsPanel,
+            onDetailsPanelToggle: () => dispatch<any>(toggleDetailsPanel()),
             onRefreshButtonClick: (id) => {
                 dispatch<any>(loadSidePanelTreeProjects(id));
                 dispatch<any>(reloadProjectMatchingUuid([id]));
@@ -80,7 +80,7 @@ export const MainContentBar =
                             </Grid>
                             <Grid item>
                                 {props.buttonVisible && <Tooltip title="Additional Info">
-                                    <IconButton color="inherit" className={props.classes.infoTooltip} onClick={props.onDetailsPanelToggle}>
+                                    <IconButton data-cy="additional-info-icon" color="inherit" className={props.classes.infoTooltip} onClick={props.onDetailsPanelToggle}>
                                         <DetailsIcon />
                                     </IconButton>
                                 </Tooltip>}
index 2f3ea6111c8729e71af0f3ca89c2ddd0aff2dc4e..ee8ce1d5ea5ffc365fa40b2334b9281bc2b53128 100644 (file)
@@ -31,11 +31,17 @@ export const ProjectsTreePicker = ({ pickerId, ...props }: ProjectsTreePickerPro
         disableActivation
     };
     return <div>
-        <HomeTreePicker pickerId={home} {...p} />
-        <SharedTreePicker pickerId={shared} {...p} />
-        <PublicFavoritesTreePicker pickerId={publicFavorites} {...p} />
+        <div data-cy="projects-tree-home-tree-picker">
+            <HomeTreePicker pickerId={home} {...p} />
+        </div>
+        <div data-cy="projects-tree-shared-tree-picker">
+            <SharedTreePicker pickerId={shared} {...p} />
+        </div>
+        <div data-cy="projects-tree-public-favourites-tree-picker">
+            <PublicFavoritesTreePicker pickerId={publicFavorites} {...p} />
+        </div>
         <div data-cy="projects-tree-favourites-tree-picker">
-            <FavoritesTreePicker pickerId={favorites} {...p} />  
+            <FavoritesTreePicker pickerId={favorites} {...p} />
         </div>
     </div>;
 };
index 2a63a31a918a1d5b9de5e80c201d8c707bdc9705..a33b6968255abd841b9eb59c7cc624453ea9344e 100644 (file)
@@ -116,7 +116,7 @@ export const Snackbar = withStyles(styles)(connect(mapStateToProps, mapDispatchT
                 onExited={props.onExited}
                 anchorOrigin={props.anchorOrigin}
                 autoHideDuration={props.autoHideDuration}>
-                <SnackbarContent
+                <div data-cy="snackbar"><SnackbarContent
                     className={classNames(cssClass)}
                     aria-describedby="client-snackbar"
                     message={
@@ -126,7 +126,7 @@ export const Snackbar = withStyles(styles)(connect(mapStateToProps, mapDispatchT
                         </span>
                     }
                     action={actions(props)}
-                />
+                /></div>
             </MaterialSnackbar>
         );
     }
@@ -151,7 +151,7 @@ const actions = (props: SnackbarProps) => {
                 color="inherit"
                 className={classes.linkButton}
                 onClick={() => onClick(link)}>
-                Go To
+                <span data-cy='snackbar-goto-action'>Go To</span>
             </Button>
         );
     }
index f9fab44d3fd52d76a55202a3a161fe16dff3b3d2..928b4fff1a9a92750af31fcdce8de29ffccefac0 100644 (file)
@@ -33,7 +33,7 @@ import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from 'st
 import { getProcess } from 'store/processes/process';
 import { ResourcesState } from 'store/resources/resources';
 
-type CssRules = "toolbar" | "button";
+type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
@@ -43,6 +43,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     button: {
         marginLeft: theme.spacing.unit
     },
+    root: {
+        width: '100%',
+    }
 });
 
 export enum AllProcessesPanelColumnNames {
@@ -142,18 +145,17 @@ export const AllProcessesPanel = withStyles(styles)(
             }
 
             render() {
-                return <DataExplorer
+                return <div className={this.props.classes.root}><DataExplorer
                     id={ALL_PROCESSES_PANEL_ID}
                     onRowClick={this.handleRowClick}
                     onRowDoubleClick={this.handleRowDoubleClick}
                     onContextMenu={this.handleContextMenu}
                     contextMenuColumn={true}
-                    dataTableDefaultView={
-                        <DataTableDefaultView
-                            icon={ProcessIcon}
-                            messages={['Processes list empty.']}
-                            />
-                    } />;
+                    dataTableDefaultView={ <DataTableDefaultView
+                        icon={ProcessIcon}
+                        messages={['Processes list empty.']}
+                        /> } />
+                </div>
             }
         }
     )
index 703bbec5ab2c82c4ed5eae321cfd76e40c02f2cb..8f87cb269eaef7a37cdfa438ebb7eef2d2100546 100644 (file)
@@ -4,10 +4,10 @@
 
 import React from 'react';
 import {
-    StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Grid, Tooltip, IconButton
+    StyleRulesCallback, WithStyles, withStyles
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { HelpIcon, ShareMeIcon } from 'components/icon/icon';
+import { ShareMeIcon } from 'components/icon/icon';
 import { createTree } from 'models/tree';
 import { DataColumns } from 'components/data-table/data-table';
 import { SortDirection } from 'components/data-table/data-column';
@@ -20,21 +20,11 @@ import {
     TokenLastUsedAt, TokenLastUsedByIpAddress, TokenScopes, TokenUserId
 } from 'views-components/data-explorer/renderers';
 
-type CssRules = 'card' | 'cardContent' | 'helpIconGrid';
+type CssRules = 'root';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    card: {
+    root: {
         width: '100%',
-        overflow: 'auto'
-    },
-    cardContent: {
-        padding: 0,
-        '&:last-child': {
-            paddingBottom: 0
-        }
-    },
-    helpIconGrid: {
-        textAlign: 'right'
     }
 });
 
@@ -132,7 +122,6 @@ export interface ApiClientAuthorizationPanelRootActionProps {
     onItemClick: (item: string) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string) => void;
     onItemDoubleClick: (item: string) => void;
-    openHelpDialog: () => void;
 }
 
 export interface ApiClientAuthorizationPanelRootDataProps {
@@ -143,33 +132,18 @@ type ApiClientAuthorizationPanelRootProps = ApiClientAuthorizationPanelRootActio
     & ApiClientAuthorizationPanelRootDataProps & WithStyles<CssRules>;
 
 export const ApiClientAuthorizationPanelRoot = withStyles(styles)(
-    ({ classes, onItemDoubleClick, onItemClick, onContextMenu, openHelpDialog }: ApiClientAuthorizationPanelRootProps) =>
-        <Card className={classes.card}>
-            <CardContent className={classes.cardContent}>
-                <Grid container direction="row" justify="flex-end">
-                    <Grid item xs={12} className={classes.helpIconGrid}>
-                        <Tooltip title="Api token - help">
-                            <IconButton onClick={openHelpDialog}>
-                                <HelpIcon />
-                            </IconButton>
-                        </Tooltip>
-                    </Grid>
-                    <Grid item xs={12}>
-                        <DataExplorer
-                            id={API_CLIENT_AUTHORIZATION_PANEL_ID}
-                            onRowClick={onItemClick}
-                            onRowDoubleClick={onItemDoubleClick}
-                            onContextMenu={onContextMenu}
-                            contextMenuColumn={true}
-                            hideColumnSelector
-                            hideSearchInput
-                            dataTableDefaultView={
-                                <DataTableDefaultView
-                                    icon={ShareMeIcon}
-                                    messages={[DEFAULT_MESSAGE]} />
-                            } />
-                    </Grid>
-                </Grid>
-            </CardContent>
-        </Card>
+    ({ classes, onItemDoubleClick, onItemClick, onContextMenu }: ApiClientAuthorizationPanelRootProps) =>
+        <div className={classes.root}><DataExplorer
+            id={API_CLIENT_AUTHORIZATION_PANEL_ID}
+            onRowClick={onItemClick}
+            onRowDoubleClick={onItemDoubleClick}
+            onContextMenu={onContextMenu}
+            contextMenuColumn={true}
+            hideColumnSelector
+            hideSearchInput
+            dataTableDefaultView={
+                <DataTableDefaultView
+                    icon={ShareMeIcon}
+                    messages={[DEFAULT_MESSAGE]} />
+            } /></div>
 );
\ No newline at end of file
index 89254dcc61b38b20ae99303a7f891856383bab11..9604bf50d0309a4bced9daa092e1a0df06c26aa4 100644 (file)
@@ -11,7 +11,6 @@ import {
     ApiClientAuthorizationPanelRootActionProps
 } from 'views/api-client-authorization-panel/api-client-authorization-panel-root';
 import { openApiClientAuthorizationContextMenu } from 'store/context-menu/context-menu-actions';
-import { openApiClientAuthorizationsHelpDialog } from 'store/api-client-authorizations/api-client-authorizations-actions';
 
 const mapStateToProps = (state: RootState): ApiClientAuthorizationPanelRootDataProps => {
     return {
@@ -25,9 +24,6 @@ const mapDispatchToProps = (dispatch: Dispatch): ApiClientAuthorizationPanelRoot
     },
     onItemClick: (resourceUuid: string) => { return; },
     onItemDoubleClick: uuid => { return; },
-    openHelpDialog: () => {
-        dispatch<any>(openApiClientAuthorizationsHelpDialog());
-    }
 });
 
 export const ApiClientAuthorizationPanel = connect(mapStateToProps, mapDispatchToProps)(ApiClientAuthorizationPanelRoot);
\ No newline at end of file
index 88638085fa736fc891c43aafbe2072e834eed291..f1278049963b7804b4681b4e1898a5d3f65e4c26 100644 (file)
@@ -7,7 +7,6 @@ import {
     StyleRulesCallback,
     WithStyles,
     withStyles,
-    Grid,
     Button
 } from '@material-ui/core';
 import { CollectionIcon } from 'components/icon/icon';
@@ -38,7 +37,7 @@ import { getResource, ResourcesState } from 'store/resources/resources';
 import { RootState } from 'store/store';
 import { CollectionResource } from 'models/collection';
 
-type CssRules = 'backLink' | 'backIcon' | 'card' | 'title' | 'iconHeader' | 'link';
+type CssRules = 'backLink' | 'backIcon' | 'root' | 'content';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     backLink: {
@@ -53,24 +52,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     backIcon: {
         marginRight: theme.spacing.unit
     },
-    card: {
-        width: '100%'
+    root: {
+        width: '100%',
     },
-    title: {
-        color: theme.palette.grey["700"]
+    content: {
+        // reserve space for the content address bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
     },
-    iconHeader: {
-        fontSize: '1.875rem',
-        color: theme.customs.colors.green700
-    },
-    link: {
-        fontSize: '0.875rem',
-        color: theme.palette.primary.main,
-        textAlign: 'right',
-        '&:hover': {
-            cursor: 'pointer'
-        }
-    }
 });
 
 enum CollectionContentAddressPanelColumnNames {
@@ -162,14 +150,14 @@ export const CollectionsContentAddressPanel = withStyles(styles)(
     connect(mapStateToProps, mapDispatchToProps)(
         class extends React.Component<CollectionContentAddressPanelActionProps & CollectionContentAddressPanelDataProps & CollectionContentAddressDataProps & WithStyles<CssRules>> {
             render() {
-                return <Grid item xs={12}>
+                return <div className={this.props.classes.root}>
                     <Button
                         onClick={() => window.history.back()}
                         className={this.props.classes.backLink}>
                         <BackIcon className={this.props.classes.backIcon} />
                         Back
                     </Button>
-                    <DataExplorer
+                    <div className={this.props.classes.content}><DataExplorer
                         id={COLLECTIONS_CONTENT_ADDRESS_PANEL_ID}
                         hideSearchInput
                         onRowClick={this.props.onItemClick}
@@ -181,8 +169,8 @@ export const CollectionsContentAddressPanel = withStyles(styles)(
                             <DataTableDefaultView
                                 icon={CollectionIcon}
                                 messages={['Collections with this content address not found.']} />
-                        } />;
-                    </Grid >;
+                        } /></div>
+                    </div>;
             }
         }
     )
index e78b1f3d04c2b3494195479a9a3d959b9f5032cb..794e093f3d8d2129c5be959f97a4c6239b7b701a 100644 (file)
@@ -4,15 +4,20 @@
 
 import React from 'react';
 import {
-    StyleRulesCallback, WithStyles, withStyles,
-    IconButton, Grid, Tooltip, Typography, ExpansionPanel,
-    ExpansionPanelSummary, ExpansionPanelDetails
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    IconButton,
+    Grid,
+    Tooltip,
+    Typography,
+    Card, CardHeader, CardContent,
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
 import { ArvadosTheme } from 'common/custom-theme';
 import { RootState } from 'store/store';
-import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon, ExpandIcon, CollectionOldVersionIcon } from 'components/icon/icon';
+import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon, CollectionOldVersionIcon } from 'components/icon/icon';
 import { DetailsAttribute } from 'components/details-attribute/details-attribute';
 import { CollectionResource, getCollectionUrl } from 'models/collection';
 import { CollectionPanelFiles } from 'views-components/collection-panel-files/collection-panel-files';
@@ -33,9 +38,12 @@ import { COLLECTION_PANEL_LOAD_FILES, loadCollectionFiles, COLLECTION_PANEL_LOAD
 import { Link } from 'react-router-dom';
 import { Link as ButtonLink } from '@material-ui/core';
 import { ResourceOwnerWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
+import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 
 type CssRules = 'root'
     | 'button'
+    | 'infoCard'
+    | 'propertiesCard'
     | 'filesCard'
     | 'iconHeader'
     | 'tag'
@@ -49,16 +57,21 @@ type CssRules = 'root'
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
-        display: 'flex',
-        flexFlow: 'column',
-        height: 'calc(100vh - 130px)', // (100% viewport height) - (top bar + breadcrumbs)
+        width: '100%',
     },
     button: {
         cursor: 'pointer'
     },
+    infoCard: {
+        paddingLeft: theme.spacing.unit * 2,
+        paddingRight: theme.spacing.unit * 2,
+        paddingBottom: theme.spacing.unit * 2,
+    },
+    propertiesCard: {
+        padding: 0,
+    },
     filesCard: {
-        marginBottom: theme.spacing.unit * 2,
-        flex: 1,
+        padding: 0,
     },
     iconHeader: {
         fontSize: '1.875rem',
@@ -133,10 +146,15 @@ export const CollectionPanel = withStyles(styles)(
         class extends React.Component<CollectionPanelProps> {
             render() {
                 const { classes, item, dispatch, isWritable, isOldVersion, isLoadingFiles, tooManyFiles } = this.props;
+                const panelsData: MPVPanelState[] = [
+                    {name: "Details"},
+                    {name: "Properties"},
+                    {name: "Files"},
+                ];
                 return item
-                    ? <div className={classes.root}>
-                        <ExpansionPanel data-cy='collection-info-panel' defaultExpanded>
-                            <ExpansionPanelSummary expandIcon={<ExpandIcon />}>
+                    ? <MPVContainer className={classes.root} spacing={8} direction="column" justify-content="flex-start" wrap="nowrap" panelStates={panelsData}>
+                        <MPVPanelContent xs="auto" data-cy='collection-info-panel'>
+                            <Card className={classes.infoCard}>
                                 <Grid container justify="space-between">
                                     <Grid item xs={11}><span>
                                         <IconButton onClick={this.openCollectionDetails}>
@@ -165,8 +183,6 @@ export const CollectionPanel = withStyles(styles)(
                                         </Tooltip>
                                     </Grid>
                                 </Grid>
-                            </ExpansionPanelSummary>
-                            <ExpansionPanelDetails>
                                 <Grid container justify="space-between">
                                     <Grid item xs={12}>
                                         <Typography variant="caption">
@@ -185,15 +201,12 @@ export const CollectionPanel = withStyles(styles)(
                                         }
                                     </Grid>
                                 </Grid>
-                            </ExpansionPanelDetails>
-                        </ExpansionPanel>
-
-                        <ExpansionPanel data-cy='collection-properties-panel' defaultExpanded>
-                            <ExpansionPanelSummary expandIcon={<ExpandIcon />}>
-                                {"Properties"}
-                            </ExpansionPanelSummary>
-                            <ExpansionPanelDetails>
-                                <Grid container>
+                            </Card>
+                        </MPVPanelContent>
+                        <MPVPanelContent xs="auto" data-cy='collection-properties-panel'>
+                            <Card className={classes.propertiesCard}>
+                                <CardHeader title="Properties" />
+                                <CardContent><Grid container>
                                     {isWritable && <Grid item xs={12}>
                                         <CollectionTagForm />
                                     </Grid>}
@@ -218,21 +231,23 @@ export const CollectionPanel = withStyles(styles)(
                                             : <div className={classes.centeredLabel}>No properties set on this collection.</div>
                                         }
                                     </Grid>
-                                </Grid>
-                            </ExpansionPanelDetails>
-                        </ExpansionPanel>
-                        <div className={classes.filesCard}>
-                            <CollectionPanelFiles
-                                isWritable={isWritable}
-                                isLoading={isLoadingFiles}
-                                tooManyFiles={tooManyFiles}
-                                loadFilesFunc={() => {
-                                    dispatch(collectionPanelActions.LOAD_BIG_COLLECTIONS(true));
-                                    dispatch<any>(loadCollectionFiles(this.props.item.uuid));
-                                }
-                                } />
-                        </div>
-                    </div>
+                                </Grid></CardContent>
+                            </Card>
+                        </MPVPanelContent>
+                        <MPVPanelContent xs>
+                            <Card className={classes.filesCard}>
+                                <CollectionPanelFiles
+                                    isWritable={isWritable}
+                                    isLoading={isLoadingFiles}
+                                    tooManyFiles={tooManyFiles}
+                                    loadFilesFunc={() => {
+                                        dispatch(collectionPanelActions.LOAD_BIG_COLLECTIONS(true));
+                                        dispatch<any>(loadCollectionFiles(this.props.item.uuid));
+                                    }
+                                    } />
+                            </Card>
+                        </MPVPanelContent>
+                    </MPVContainer>
                     : null;
             }
 
index 404baeb9d1c274ac199dc6ced4fb557963a47e1e..0b6532c13ed383fbb876cb972bae452d826f3b21 100644 (file)
@@ -41,7 +41,7 @@ import { getProperty } from 'store/properties/properties';
 import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
 import { CollectionResource } from 'models/collection';
 
-type CssRules = "toolbar" | "button";
+type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
@@ -51,6 +51,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     button: {
         marginLeft: theme.spacing.unit
     },
+    root: {
+        width: '100%',
+    },
 });
 
 export enum FavoritePanelColumnNames {
@@ -176,7 +179,7 @@ export const FavoritePanel = withStyles(styles)(
             }
 
             render() {
-                return <DataExplorer
+                return <div className={this.props.classes.root}><DataExplorer
                     id={FAVORITE_PANEL_ID}
                     onRowClick={this.handleRowClick}
                     onRowDoubleClick={this.handleRowDoubleClick}
@@ -187,7 +190,7 @@ export const FavoritePanel = withStyles(styles)(
                             icon={FavoriteIcon}
                             messages={['Your favorites list is empty.']}
                             />
-                    } />;
+                    } /></div>;
             }
         }
     )
index e47ff8c0cc114eef30147b75ffa434b366a12398..ce3f34c75348d39c4dbd1ee63df9b2e06a70d73c 100644 (file)
@@ -14,10 +14,19 @@ import { RootState } from 'store/store';
 import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID, openAddGroupMembersDialog, getCurrentGroupDetailsPanelUuid } from 'store/group-details-panel/group-details-panel-actions';
 import { openContextMenu } from 'store/context-menu/context-menu-actions';
 import { ResourcesState, getResource } from 'store/resources/resources';
-import { Grid, Button, Tabs, Tab, Paper } from '@material-ui/core';
+import { Grid, Button, Tabs, Tab, Paper, WithStyles, withStyles, StyleRulesCallback } from '@material-ui/core';
 import { AddIcon } from 'components/icon/icon';
 import { getUserUuid } from 'common/getuser';
 import { GroupResource, isBuiltinGroup } from 'models/group';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
 
 export enum GroupDetailsPanelMembersColumnNames {
     FULL_NAME = "Name",
@@ -136,10 +145,10 @@ export interface GroupDetailsPanelProps {
     groupCanManage: boolean;
 }
 
-export const GroupDetailsPanel = connect(
+export const GroupDetailsPanel = withStyles(styles)(connect(
     mapStateToProps, mapDispatchToProps
 )(
-    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps> {
+    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
         state = {
           value: 0,
         };
@@ -151,7 +160,7 @@ export const GroupDetailsPanel = connect(
         render() {
             const { value } = this.state;
             return (
-                <Paper>
+                <Paper className={this.props.classes.root}>
                   <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
                       <Tab data-cy="group-details-members-tab" label="MEMBERS" />
                       <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
@@ -203,4 +212,4 @@ export const GroupDetailsPanel = connect(
         handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
             this.setState({ value });
         }
-    });
+    }));
index c96c06775376ae0eb47e35e464ce52b6cba54885..3251c729eee32d6df8d75a4c298d38d9bb0e8c4b 100644 (file)
@@ -4,7 +4,7 @@
 
 import React from 'react';
 import { connect } from 'react-redux';
-import { Grid, Button, Typography } from "@material-ui/core";
+import { Grid, Button, Typography, StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
 import { DataExplorer } from "views-components/data-explorer/data-explorer";
 import { DataColumns } from 'components/data-table/data-table';
 import { SortDirection } from 'components/data-table/data-column';
@@ -21,6 +21,15 @@ import { RootState } from 'store/store';
 import { openContextMenu } from 'store/context-menu/context-menu-actions';
 import { ResourceKind } from 'models/resource';
 import { LinkClass, LinkResource } from 'models/link';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
 
 export enum GroupsPanelColumnNames {
     GROUP = "Name",
@@ -70,14 +79,14 @@ export interface GroupsPanelProps {
     resources: ResourcesState;
 }
 
-export const GroupsPanel = connect(
+export const GroupsPanel = withStyles(styles)(connect(
     mapStateToProps, mapDispatchToProps
 )(
-    class GroupsPanel extends React.Component<GroupsPanelProps> {
+    class GroupsPanel extends React.Component<GroupsPanelProps & WithStyles<CssRules>> {
 
         render() {
             return (
-                <DataExplorer
+                <div className={this.props.classes.root}><DataExplorer
                     id={GROUPS_PANEL_ID}
                     data-cy="groups-panel-data-explorer"
                     onRowClick={noop}
@@ -95,7 +104,7 @@ export const GroupsPanel = connect(
                                 <AddIcon /> New group
                         </Button>
                         </Grid>
-                    } />
+                    } /></div>
             );
         }
 
@@ -112,7 +121,7 @@ export const GroupsPanel = connect(
                 });
             }
         }
-    });
+    }));
 
 
 const GroupMembersCount = connect(
index 7a5f0503fb46b42dc67f868709bd9ec573cefc35..b32208cd74a04895c569d5e227b2cf037b4207e3 100644 (file)
@@ -11,10 +11,20 @@ import { DataTableDefaultView } from 'components/data-table-default-view/data-ta
 import { ResourcesState } from 'store/resources/resources';
 import { ShareMeIcon } from 'components/icon/icon';
 import { createTree } from 'models/tree';
-import { 
-    ResourceLinkUuid, ResourceLinkHead, ResourceLinkTail, 
-    ResourceLinkClass, ResourceLinkName } 
+import {
+    ResourceLinkUuid, ResourceLinkHead, ResourceLinkTail,
+    ResourceLinkClass, ResourceLinkName }
 from 'views-components/data-explorer/renderers';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
 
 export enum LinkPanelColumnNames {
     NAME = "Name",
@@ -73,20 +83,20 @@ export interface LinkPanelRootActionProps {
     onItemDoubleClick: (item: string) => void;
 }
 
-export type LinkPanelRootProps = LinkPanelRootDataProps & LinkPanelRootActionProps;
+export type LinkPanelRootProps = LinkPanelRootDataProps & LinkPanelRootActionProps & WithStyles<CssRules>;
 
-export const LinkPanelRoot = (props: LinkPanelRootProps) => {
-    return <DataExplorer
+export const LinkPanelRoot = withStyles(styles)((props: LinkPanelRootProps) => {
+    return <div className={props.classes.root}><DataExplorer
         id={LINK_PANEL_ID}
         onRowClick={props.onItemClick}
         onRowDoubleClick={props.onItemDoubleClick}
         onContextMenu={props.onContextMenu}
-        contextMenuColumn={true} 
+        contextMenuColumn={true}
         hideColumnSelector
         hideSearchInput
         dataTableDefaultView={
             <DataTableDefaultView
                 icon={ShareMeIcon}
                 messages={['Your link list is empty.']} />
-        }/>;
-};
\ No newline at end of file
+        }/></div>;
+});
\ No newline at end of file
diff --git a/src/views/process-panel/process-details-attributes.tsx b/src/views/process-panel/process-details-attributes.tsx
new file mode 100644 (file)
index 0000000..4f26a71
--- /dev/null
@@ -0,0 +1,65 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { Grid } from "@material-ui/core";
+import { formatDate } from "common/formatters";
+import { resourceLabel } from "common/labels";
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { ProcessResource } from "models/process";
+import { ResourceKind } from "models/resource";
+import { ResourceOwnerWithName } from "views-components/data-explorer/renderers";
+
+type CssRules = 'label' | 'value';
+
+export const ProcessDetailsAttributes = (props: { item: ProcessResource, twoCol?: boolean, classes?: Record<CssRules, string> }) => {
+    const item = props.item;
+    const classes = props.classes || { label: '', value: '', button: '' };
+    const mdSize = props.twoCol ? 6 : 12;
+    return <Grid container>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                label='Owner' linkToUuid={item.ownerUuid}
+                uuidEnhancer={(uuid: string) => <ResourceOwnerWithName uuid={uuid} />} />
+        </Grid>
+        <Grid item xs={12} md={12}>
+            <DetailsAttribute label='Status' value={item.state} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Last modified' value={formatDate(item.modifiedAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Started at' value={formatDate(item.createdAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Created at' value={formatDate(item.createdAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Finished at' value={formatDate(item.expiresAt)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Outputs' value={item.outputPath} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='UUID' linkToUuid={item.uuid} value={item.uuid} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Container UUID' value={item.containerUuid} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Priority' value={item.priority} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Runtime Constraints'
+            value={JSON.stringify(item.runtimeConstraints)} />
+        </Grid>
+        <Grid item xs={12} md={mdSize}>
+            <DetailsAttribute label='Docker Image locator'
+            linkToUuid={item.containerImage} value={item.containerImage} />
+        </Grid>
+    </Grid>;
+};
diff --git a/src/views/process-panel/process-details-card.tsx b/src/views/process-panel/process-details-card.tsx
new file mode 100644 (file)
index 0000000..1861078
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon } from 'components/icon/icon';
+import { Process } from 'store/processes/process';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { ProcessDetailsAttributes } from './process-details-attributes';
+
+type CssRules = 'card' | 'content' | 'title';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    content: {
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 2,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+});
+
+export interface ProcessDetailsCardDataProps {
+    process: Process;
+}
+
+type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessDetailsCard = withStyles(styles)(
+    ({ classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+        return <Card className={classes.card}>
+            <CardHeader
+                classes={{
+                    content: classes.title,
+                }}
+                title='Details'
+                action={ doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> } />
+            <CardContent className={classes.content}>
+                <ProcessDetailsAttributes item={process.containerRequest} twoCol />
+            </CardContent>
+        </Card>;
+    }
+);
+
index e70a047898d878b0a5947a0d85ac01204ae6e151..4c93801707864341a4698b93bb4449827ecd32cf 100644 (file)
@@ -8,13 +8,14 @@ import {
     CardHeader, IconButton, CardContent, Grid, Chip, Typography, Tooltip
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
+import { CloseIcon, MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
 import { DetailsAttribute } from 'components/details-attribute/details-attribute';
 import { Process } from 'store/processes/process';
 import { getProcessStatus, getProcessStatusColor } from 'store/processes/process';
 import { formatDate } from 'common/formatters';
 import classNames from 'classnames';
 import { ContainerState } from 'models/container';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 
 type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'link' | 'content' | 'title' | 'avatar' | 'cancelButton';
 
@@ -83,10 +84,10 @@ export interface ProcessInformationCardDataProps {
     cancelProcess: (uuid: string) => void;
 }
 
-type ProcessInformationCardProps = ProcessInformationCardDataProps & WithStyles<CssRules, true>;
+type ProcessInformationCardProps = ProcessInformationCardDataProps & WithStyles<CssRules, true> & MPVPanelProps;
 
 export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
-    ({ classes, process, onContextMenu, theme, openProcessInputDialog, navigateToOutput, openWorkflow, cancelProcess }: ProcessInformationCardProps) => {
+    ({ classes, process, onContextMenu, theme, openProcessInputDialog, navigateToOutput, openWorkflow, cancelProcess, doHidePanel, panelName }: ProcessInformationCardProps) => {
         const { container } = process;
         const startedAt = container ? formatDate(container.startedAt) : 'N/A';
         const finishedAt = container ? formatDate(container.finishedAt) : 'N/A';
@@ -111,6 +112,10 @@ export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
                                 <MoreOptionsIcon />
                             </IconButton>
                         </Tooltip>
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
                     </div>
                 }
                 title={
index e7f66573ae02af087d4320eae88ca0c29166a29f..deb5f1b0dde8f19f46ef4bd5ed4889159953e78c 100644 (file)
@@ -3,13 +3,24 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { Grid } from '@material-ui/core';
+import { Grid, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { ProcessInformationCard } from './process-information-card';
 import { DefaultView } from 'components/default-view/default-view';
 import { ProcessIcon } from 'components/icon/icon';
 import { Process } from 'store/processes/process';
 import { SubprocessPanel } from 'views/subprocess-panel/subprocess-panel';
 import { SubprocessFilterDataProps } from 'components/subprocess-filter/subprocess-filter';
+import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ProcessDetailsCard } from './process-details-card';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+});
 
 export interface ProcessPanelRootDataProps {
     process?: Process;
@@ -26,12 +37,18 @@ export interface ProcessPanelRootActionProps {
     cancelProcess: (uuid: string) => void;
 }
 
-export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps;
+export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
+
+const panelsData: MPVPanelState[] = [
+    {name: "Info"},
+    {name: "Details", visible: false},
+    {name: "Subprocesses"},
+];
 
-export const ProcessPanelRoot = ({ process, ...props }: ProcessPanelRootProps) =>
+export const ProcessPanelRoot = withStyles(styles)(({ process, ...props }: ProcessPanelRootProps) =>
     process
-        ? <Grid container spacing={16} alignItems="stretch">
-            <Grid item sm={12} md={12}>
+        ? <MPVContainer className={props.classes.root} spacing={8} panelStates={panelsData}  justify-content="flex-start" direction="column" wrap="nowrap">
+            <MPVPanelContent xs="auto">
                 <ProcessInformationCard
                     process={process}
                     onContextMenu={event => props.onContextMenu(event, process)}
@@ -40,11 +57,14 @@ export const ProcessPanelRoot = ({ process, ...props }: ProcessPanelRootProps) =
                     openWorkflow={props.navigateToWorkflow}
                     cancelProcess={props.cancelProcess}
                 />
-            </Grid>
-            <Grid item sm={12} md={12}>
+            </MPVPanelContent>
+            <MPVPanelContent xs="auto">
+                <ProcessDetailsCard process={process} />
+            </MPVPanelContent>
+            <MPVPanelContent xs>
                 <SubprocessPanel />
-            </Grid>
-        </Grid>
+            </MPVPanelContent>
+        </MPVContainer>
         : <Grid container
             alignItems='center'
             justify='center'
@@ -52,5 +72,5 @@ export const ProcessPanelRoot = ({ process, ...props }: ProcessPanelRootProps) =
             <DefaultView
                 icon={ProcessIcon}
                 messages={['Process not found']} />
-        </Grid>;
+        </Grid>);
 
index 672645111743769f0dcec12644277479e43f0c8a..4a3f60a619badef42c72e3b5ed5a56ba97868f3a 100644 (file)
@@ -51,9 +51,7 @@ type CssRules = 'root' | "button";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
-        position: 'relative',
         width: '100%',
-        height: '100%'
     },
     button: {
         marginLeft: theme.spacing.unit
@@ -147,7 +145,7 @@ export const ProjectPanel = withStyles(styles)(
         class extends React.Component<ProjectPanelProps> {
             render() {
                 const { classes } = this.props;
-                return <div className={classes.root}>
+                return <div data-cy='project-panel' className={classes.root}>
                     <DataExplorer
                         id={PROJECT_PANEL_ID}
                         onRowClick={this.handleRowClick}
index ee09654a998ec0ea74df07be3c8bb25e28046374..b58aa2f09f3fbf73b3ee91093b9d346e3d7a6be0 100644 (file)
@@ -39,7 +39,7 @@ import { getResource, ResourcesState } from 'store/resources/resources';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { CollectionResource } from 'models/collection';
 
-type CssRules = "toolbar" | "button";
+type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
@@ -49,6 +49,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     button: {
         marginLeft: theme.spacing.unit
     },
+    root: {
+        width: '100%',
+    },
 });
 
 export enum PublicFavoritePanelColumnNames {
@@ -160,7 +163,7 @@ export const PublicFavoritePanel = withStyles(styles)(
     connect(mapStateToProps, mapDispatchToProps)(
         class extends React.Component<FavoritePanelProps> {
             render() {
-                return <DataExplorer
+                return <div className={this.props.classes.root}><DataExplorer
                     id={PUBLIC_FAVORITE_PANEL_ID}
                     onRowClick={this.props.onItemClick}
                     onRowDoubleClick={this.props.onItemDoubleClick}
@@ -170,7 +173,7 @@ export const PublicFavoritePanel = withStyles(styles)(
                         <DataTableDefaultView
                             icon={PublicFavoriteIcon}
                             messages={['Public favorites list is empty.']} />
-                    } />;
+                    } /></div>;
             }
         }
     )
index 780cbc90fedc7e8a45e93b798fd3843c433a3d6d..3f0a5334fa468c2deba7095435937bd0e2ef3bac 100644 (file)
@@ -30,7 +30,7 @@ const validationSelector = createSelector(
 );
 
 const required = (value: string[]) =>
-    value.length > 0
+    value && value.length > 0
         ? undefined
         : ERROR_MESSAGE;
 
index 03cb07ea3e1368ec3829e502a2b26376356471f0..8077f28a600342cf95b3e77cf0980a17a4b30d0c 100644 (file)
@@ -30,7 +30,7 @@ const validationSelector = createSelector(
 );
 
 const required = (value: string[]) =>
-    value.length > 0
+    value && value.length > 0
         ? undefined
         : ERROR_MESSAGE;
 
index cabbf749816fa844f4293418daa7298a176f2686..8955009a0d52984a9393b675e9721825f4a746d0 100644 (file)
@@ -31,7 +31,7 @@ const validationSelector = createSelector(
 );
 
 const required = (value: string[] = []) =>
-    value.length > 0
+    value && value.length > 0
         ? undefined
         : ERROR_MESSAGE;
 
index 906d3a37b652e06426090c946d5c4d42cc3525e7..ed6d5640ed44c068879222e7d72a5683c6645d14 100644 (file)
@@ -59,7 +59,7 @@ export const RunProcessFirstStep = withStyles(styles)(
         <Grid container spacing={16}>
             <Grid container item xs={6} className={classes.root}>
                 <Grid item xs={12} className={classes.searchGrid}>
-                    <SearchInput value='' onSearch={onSearch} />
+                    <SearchInput selfClearProp={JSON.stringify(selectedWorkflow)} value='' onSearch={onSearch} />
                 </Grid>
                 <Grid item xs={12}>
                     <List className={classes.list}>
index eb3127a7612f257ae0baa639998cfd56427ec008..219410c54e3583655e90ca7f2e82e9a05ccfdf63 100644 (file)
@@ -20,7 +20,7 @@ import {
 } from 'store/context-menu/context-menu-actions';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 
-type CssRules = "toolbar" | "button";
+type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
@@ -30,6 +30,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     button: {
         marginLeft: theme.spacing.unit
     },
+    root: {
+        width: '100%',
+    },
 });
 
 interface SharedWithMePanelDataProps {
@@ -46,13 +49,13 @@ export const SharedWithMePanel = withStyles(styles)(
     }))(
         class extends React.Component<SharedWithMePanelProps> {
             render() {
-                return <DataExplorer
+                return <div className={this.props.classes.root}><DataExplorer
                     id={SHARED_WITH_ME_PANEL_ID}
                     onRowClick={this.handleRowClick}
                     onRowDoubleClick={this.handleRowDoubleClick}
                     onContextMenu={this.handleContextMenu}
                     contextMenuColumn={false}
-                    dataTableDefaultView={<DataTableDefaultView icon={ShareMeIcon} />} />;
+                    dataTableDefaultView={<DataTableDefaultView icon={ShareMeIcon} />} /></div>;
             }
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
index b8e1b08141a3219baed3013c9b8a93cafbed1036..41a8f66b80457a6fdbe693a8f0884e6a7d7edb07 100644 (file)
@@ -17,6 +17,7 @@ import { DataTableDefaultView } from 'components/data-table-default-view/data-ta
 import { createTree } from 'models/tree';
 import { getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 import { ResourcesState } from 'store/resources/resources';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 
 export enum SubprocessPanelColumnNames {
     NAME = "Name",
@@ -80,7 +81,7 @@ const DEFAULT_VIEW_MESSAGES = [
     'The current process may not have any or none matches current filtering.'
 ];
 
-export const SubprocessPanelRoot = (props: SubprocessPanelProps) => {
+export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) => {
     return <DataExplorer
         id={SUBPROCESS_PANEL_ID}
         onRowClick={props.onItemClick}
@@ -91,5 +92,9 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps) => {
             <DataTableDefaultView
                 icon={ProcessIcon}
                 messages={DEFAULT_VIEW_MESSAGES} />
-        } />;
+        }
+        doHidePanel={props.doHidePanel}
+        doMaximizePanel={props.doMaximizePanel}
+        panelMaximized={props.panelMaximized}
+        panelName={props.panelName} />;
 };
\ No newline at end of file
index b67b666c973239a55215e46daee95010c042b5f3..d303c2f700713337364af00f9965812eefade800 100644 (file)
@@ -36,7 +36,7 @@ import {
     getTrashPanelTypeFilters
 } from 'store/resource-type-filters/resource-type-filters';
 
-type CssRules = "toolbar" | "button";
+type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
@@ -46,6 +46,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     button: {
         marginLeft: theme.spacing.unit
     },
+    root: {
+        width: '100%',
+    },
 });
 
 export enum TrashPanelColumnNames {
@@ -146,7 +149,7 @@ export const TrashPanel = withStyles(styles)(
     }))(
         class extends React.Component<TrashPanelProps> {
             render() {
-                return <DataExplorer
+                return <div className={this.props.classes.root}><DataExplorer
                     id={TRASH_PANEL_ID}
                     onRowClick={this.handleRowClick}
                     onRowDoubleClick={this.handleRowDoubleClick}
@@ -156,7 +159,7 @@ export const TrashPanel = withStyles(styles)(
                         <DataTableDefaultView
                             icon={TrashIcon}
                             messages={['Your trash list is empty.']}/>
-                    } />;
+                    } /></div>;
             }
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
index c86ca519ecca23f09bc95b670f53000429d207f2..5fb979a2194c62af81a0de64b37cdc509096ead0 100644 (file)
@@ -30,7 +30,7 @@ import { ShareMeIcon, AddIcon } from 'components/icon/icon';
 import { USERS_PANEL_ID, openUserCreateDialog } from 'store/users/users-actions';
 import { noop } from 'lodash';
 
-type UserPanelRules = "button";
+type UserPanelRules = "button" | 'root' | 'content';
 
 const styles = withStyles<UserPanelRules>(theme => ({
     button: {
@@ -39,6 +39,13 @@ const styles = withStyles<UserPanelRules>(theme => ({
         textAlign: 'right',
         alignSelf: 'center'
     },
+    root: {
+        width: '100%',
+    },
+    content: {
+        // reserve space for the tab bar
+        height: `calc(100% - ${theme.spacing.unit * 7}px)`,
+    }
 }));
 
 export enum UserPanelColumnNames {
@@ -149,13 +156,13 @@ export const UserPanel = compose(
 
             render() {
                 const { value } = this.state;
-                return <Paper>
+                return <Paper className={this.props.classes.root}>
                     <Tabs value={value} onChange={this.handleChange} fullWidth>
                         <Tab label="USERS" />
                         <Tab label="ACTIVITY" disabled />
                     </Tabs>
                     {value === 0 &&
-                        <span>
+                        <div className={this.props.classes.content}>
                             <DataExplorer
                                 id={USERS_PANEL_ID}
                                 onRowClick={noop}
@@ -178,7 +185,7 @@ export const UserPanel = compose(
                                         icon={ShareMeIcon}
                                         messages={['Your user list is empty.']} />
                                 } />
-                        </span>}
+                        </div>}
                 </Paper>;
             }
 
index 64caa6cac5d857024965d532102107e92f48a0c2..25d70776e2f9c97ee779cf7594d577bf23f3844f 100644 (file)
@@ -127,6 +127,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         minWidth: 0,
         paddingLeft: theme.spacing.unit * 3,
         paddingRight: theme.spacing.unit * 3,
+        // Reserve vertical space for app bar + MainContentBar
+        minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`,
+        display: 'flex',
     }
 });