Merge branch '21448-menu-reorder' into 21224-project-details
authorLisa Knox <lisaknox83@gmail.com>
Fri, 19 Apr 2024 18:40:16 +0000 (14:40 -0400)
committerLisa Knox <lisaknox83@gmail.com>
Fri, 19 Apr 2024 18:40:16 +0000 (14:40 -0400)
refs #21448

Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa.knox@curii.com>

33 files changed:
services/workbench2/cypress/e2e/details-card.spec.js [new file with mode: 0644]
services/workbench2/src/components/data-explorer/data-explorer.tsx
services/workbench2/src/components/data-table/data-table.tsx
services/workbench2/src/components/expand-chevron-right/expand-chevron-right.tsx [new file with mode: 0644]
services/workbench2/src/components/multiselect-toolbar/MultiselectToolbar.tsx
services/workbench2/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts
services/workbench2/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts
services/workbench2/src/components/multiselect-toolbar/ms-toolbar-overflow-wrapper.tsx
services/workbench2/src/index.tsx
services/workbench2/src/models/details.ts
services/workbench2/src/store/details-panel/details-panel-action.ts
services/workbench2/src/store/multiselect/multiselect-reducer.tsx
services/workbench2/src/store/project-panel/project-panel-middleware-service.ts
services/workbench2/src/store/projects/project-lock-actions.ts
services/workbench2/src/store/selected-resource/selected-resource-actions.ts [new file with mode: 0644]
services/workbench2/src/store/selected-resource/selected-resource-reducer.ts [new file with mode: 0644]
services/workbench2/src/store/store.ts
services/workbench2/src/store/users/users-actions.ts
services/workbench2/src/views-components/context-menu/action-sets/user-details-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/data-explorer/data-explorer.tsx
services/workbench2/src/views-components/data-explorer/renderers.tsx
services/workbench2/src/views-components/details-panel/details-panel.tsx
services/workbench2/src/views-components/details-panel/root-project-details.tsx [new file with mode: 0644]
services/workbench2/src/views-components/main-content-bar/main-content-bar.tsx
services/workbench2/src/views-components/multiselect-toolbar/ms-menu-actions.ts
services/workbench2/src/views-components/multiselect-toolbar/ms-user-details-action-set.ts [new file with mode: 0644]
services/workbench2/src/views-components/project-details-card/project-details-card.tsx [new file with mode: 0644]
services/workbench2/src/views-components/resource-properties-form/property-chip.tsx
services/workbench2/src/views-components/resource-properties-form/resource-properties-form.tsx
services/workbench2/src/views-components/side-panel-tree/side-panel-tree.tsx
services/workbench2/src/views/main-panel/main-panel-root.tsx
services/workbench2/src/views/main-panel/main-panel.tsx
services/workbench2/src/views/project-panel/project-panel.tsx

diff --git a/services/workbench2/cypress/e2e/details-card.spec.js b/services/workbench2/cypress/e2e/details-card.spec.js
new file mode 100644 (file)
index 0000000..3fbfd97
--- /dev/null
@@ -0,0 +1,261 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('User Details Card tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser')
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('activeUser1', 'Active', 'User', false, true)
+            .as('activeUser')
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+        cy.on('uncaught:exception', (err, runnable) => {
+            console.error(err);
+        });
+    });
+
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    it('should display the user details card', () => {
+        cy.loginAs(adminUser);
+
+        cy.get('[data-cy=user-details-card]').should('be.visible');
+        cy.get('[data-cy=user-details-card]').contains(adminUser.user.full_name).should('be.visible');
+    });
+
+    it('should contain a context menu with the correct options', () => {
+        cy.loginAs(adminUser);
+
+        cy.get('[data-cy=kebab-icon]').should('be.visible').click();
+
+        //admin options
+        cy.get('[data-cy=context-menu]').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('API Details').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Account Settings').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Attributes').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Deactivate User').should('be.visible');
+
+        cy.loginAs(activeUser);
+
+        cy.get('[data-cy=kebab-icon]').should('be.visible').click();
+
+        //active user options
+        cy.get('[data-cy=context-menu]').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('API Details').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Account Settings').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Attributes').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Project').should('be.visible');
+    });
+});
+
+describe('Project Details Card tests', function () {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser')
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('activeUser1', 'Active', 'User', false, true)
+            .as('activeUser')
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+        cy.on('uncaught:exception', (err, runnable) => {
+            console.error(err);
+        });
+    });
+
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    it('should display the project details card', () => {
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(adminUser);
+
+        // Create project
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            });
+        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get('[data-cy=form-dialog]').should('not.exist');
+
+        cy.get('[data-cy=project-details-card]').should('be.visible');
+        cy.get('[data-cy=project-details-card]').contains(projName).should('be.visible');
+    });
+
+    it('should contain a context menu with the correct options', () => {
+        const adminProjName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(adminUser);
+
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            });
+        cy.get('[data-cy=form-submit-btn]').click();
+
+        cy.waitForDom();
+        cy.get('[data-cy=kebab-icon]').should('be.visible').click();
+
+        //admin options
+        cy.get('[data-cy=context-menu]').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('API Details').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Copy to clipboard').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Edit project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Move to').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('New project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Open in new tab').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Open with 3rd party client').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Share').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Add to favorites').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Freeze project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Add to public favorites').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Move to trash').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('View details').should('be.visible');
+
+        //create project
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(activeUser);
+
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            });
+        cy.get('[data-cy=form-submit-btn]').click();
+
+        cy.waitForDom();
+        cy.get('[data-cy=kebab-icon]').should('be.visible').click({ force: true });
+
+        //active user options
+        cy.get('[data-cy=context-menu]').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('API Details').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Copy to clipboard').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Edit project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Move to').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('New project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Open in new tab').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Open with 3rd party client').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Share').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Add to favorites').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Freeze project').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('Move to trash').should('be.visible');
+        cy.get('[data-cy=context-menu]').contains('View details').should('be.visible');
+    });
+
+    it('should toggle description display', () => {
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        const projDescription = 'Lorem ipsum dolor sit amet, consectetur adipiscing vultures, whose wings are dull realities.';
+        cy.loginAs(adminUser);
+
+        // Create project
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            });
+        cy.get('[data-cy=form-submit-btn]').click();
+
+        //check for no description
+        cy.get('[data-cy=no-description').should('be.visible');
+
+        //add description
+        cy.get('[data-cy=side-panel-tree]').contains('Home Projects').click();
+        cy.get('[data-cy=project-panel] tbody tr').contains(projName).rightclick({ force: true });
+        cy.get('[data-cy=context-menu]').contains('Edit').click();
+        cy.get('[data-cy=form-dialog]').within(() => {
+            cy.get('div[contenteditable=true]').click().type(projDescription);
+            cy.get('[data-cy=form-submit-btn]').click();
+        });
+        cy.get('[data-cy=project-panel] tbody tr').contains(projName).click({ force: true });
+        cy.get('[data-cy=project-details-card]').contains(projName).should('be.visible');
+
+        //toggle description
+        cy.get('[data-cy=toggle-description').click();
+        cy.waitForDom();
+        cy.get('[data-cy=project-description]').should('be.visible');
+        cy.get('[data-cy=project-details-card]').contains(projDescription).should('be.visible');
+        cy.get('[data-cy=toggle-description').click();
+        cy.waitForDom();
+        cy.get('[data-cy=project-description]').should('be.hidden');
+    });
+
+    it('should display key/value pairs', () => {
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.loginAs(adminUser);
+
+        // Create project wih key/value pairs
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            });
+
+        cy.get('[data-cy=key-input]').should('be.visible').click().type('Animal');
+        cy.get('[data-cy=value-input]').should('be.visible').click().type('Dog');
+        cy.get('[data-cy=property-add-btn]').should('be.visible').click();
+
+        cy.get('[data-cy=key-input]').should('be.visible').click().type('Importance');
+        cy.get('[data-cy=value-input]').should('be.visible').click().type('Critical');
+        cy.get('[data-cy=property-add-btn]').should('be.visible').click();
+
+        cy.get('[data-cy=form-submit-btn]').click();
+
+        //check for key/value pairs in project details card
+        cy.get('[data-cy=project-details-card]').contains('Animal').should('be.visible');
+        cy.get('[data-cy=project-details-card]').contains('Importance').should('be.visible').click();
+        cy.waitForDom();
+        cy.window().then((win) =>
+            win.navigator.clipboard.readText().then((text) => {
+                expect(text).to.match(new RegExp(`Importance: Critical`));
+            })
+        );
+    });
+});
index ba710bc783e9ca6368c5355d042a3930e677af8b..4737ed06dc6e124eb9efabd931c1f95603e4f854 100644 (file)
@@ -17,13 +17,19 @@ import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } f
 import { PaperProps } from "@material-ui/core/Paper";
 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
 
-type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container";
+type CssRules = "titleWrapper" | "msToolbarStyles" | "subpanelToolbarStyles" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     titleWrapper: {
         display: "flex",
         justifyContent: "space-between",
     },
+    msToolbarStyles: {
+        paddingTop: "0.6rem",
+    },
+    subpanelToolbarStyles: {
+        paddingTop: "1.2rem",
+    },
     searchBox: {
         paddingBottom: 0,
     },
@@ -37,6 +43,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     root: {
         height: "100%",
+        flex: 1,
+        overflowY: "auto",
     },
     moreOptionsButton: {
         padding: 0,
@@ -92,7 +100,8 @@ interface DataExplorerDataProps<T> {
     title?: React.ReactNode;
     progressBar?: React.ReactNode;
     paperKey?: string;
-    currentItemUuid: string;
+    currentRouteUuid: string;
+    selectedResourceUuid: string;
     elementPath?: string;
     isMSToolbarVisible: boolean;
     checkedList: TCheckedList;
@@ -114,6 +123,7 @@ interface DataExplorerActionProps<T> {
     extractKey?: (item: T) => React.Key;
     toggleMSToolbar: (isVisible: boolean) => void;
     setCheckedListOnStore: (checkedList: TCheckedList) => void;
+    setSelectedUuid: (uuid: string) => void;
 }
 
 type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
@@ -155,7 +165,7 @@ export const DataExplorer = withStyles(styles)(
                 hideSearchInput,
                 paperKey,
                 fetchMode,
-                currentItemUuid,
+                selectedResourceUuid,
                 currentRoute,
                 title,
                 progressBar,
@@ -194,7 +204,7 @@ export const DataExplorer = withStyles(styles)(
                                 </Grid>
                             )}
                             {!!progressBar && progressBar}
-                            {this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                            {this.multiSelectToolbarInTitle && <MultiselectToolbar injectedStyles={classes.msToolbarStyles} />}
                             {(!hideColumnSelector || !hideSearchInput || !!actions) && (
                                 <Grid
                                     className={classes.headerMenu}
@@ -260,7 +270,7 @@ export const DataExplorer = withStyles(styles)(
                                 </Grid>
                             )}
                         </div>
-                        {!this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                        {!this.multiSelectToolbarInTitle && <MultiselectToolbar isSubPanel={true} injectedStyles={classes.subpanelToolbarStyles}/>}
                         <Grid
                             item
                             xs="auto"
@@ -278,11 +288,13 @@ export const DataExplorer = withStyles(styles)(
                                 extractKey={extractKey}
                                 defaultViewIcon={defaultViewIcon}
                                 defaultViewMessages={defaultViewMessages}
-                                currentItemUuid={currentItemUuid}
                                 currentRoute={paperKey}
                                 toggleMSToolbar={toggleMSToolbar}
                                 setCheckedListOnStore={setCheckedListOnStore}
                                 checkedList={checkedList}
+                                selectedResourceUuid={selectedResourceUuid}
+                                setSelectedUuid={this.props.setSelectedUuid}
+                                currentRouteUuid={this.props.currentRouteUuid}
                                 working={working}
                                 isNotFound={this.props.isNotFound}
                             />
index 7b787994577305ea5725b80fcfd942d42f34f360..e7a358580c77e8802e0b166d5b8bfd376bb9824b 100644 (file)
@@ -29,6 +29,7 @@ import { SvgIconProps } from "@material-ui/core/SvgIcon";
 import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
 import { createTree } from "models/tree";
 import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover";
+import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
 import { PendingIcon } from "components/icon/icon";
 
 export type DataColumns<I, R> = Array<DataColumn<I, R>>;
@@ -50,11 +51,13 @@ export interface DataTableDataProps<I> {
     working?: boolean;
     defaultViewIcon?: IconType;
     defaultViewMessages?: string[];
-    currentItemUuid?: string;
-    currentRoute?: string;
     toggleMSToolbar: (isVisible: boolean) => void;
     setCheckedListOnStore: (checkedList: TCheckedList) => void;
+    currentRoute?: string;
+    currentRouteUuid: string;
     checkedList: TCheckedList;
+    selectedResourceUuid: string;
+    setSelectedUuid: (uuid: string | null) => void;
     isNotFound?: boolean;
 }
 
@@ -159,8 +162,9 @@ export const DataTable = withStyles(styles)(
         }
 
         componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
-            const { items, setCheckedListOnStore } = this.props;
+            const { items, currentRouteUuid, setCheckedListOnStore } = this.props;
             const { isSelected } = this.state;
+            const singleSelected = isExactlyOneSelected(this.props.checkedList);
             if (prevProps.items !== items) {
                 if (isSelected === true) this.setState({ isSelected: false });
                 if (items.length) this.initializeCheckedList(items);
@@ -169,6 +173,15 @@ export const DataTable = withStyles(styles)(
             if (prevProps.currentRoute !== this.props.currentRoute) {
                 this.initializeCheckedList([])
             }
+            if (singleSelected && singleSelected !== isExactlyOneSelected(prevProps.checkedList)) {
+                this.props.setSelectedUuid(singleSelected);
+            }
+            if (!singleSelected && !!currentRouteUuid && !this.isAnySelected()) {
+                this.props.setSelectedUuid(currentRouteUuid);
+            }
+            if (!singleSelected && this.isAnySelected()) {
+                this.props.setSelectedUuid(null);
+            }
             if(prevProps.working === true && this.props.working === false) {
                 this.setState({ isLoaded: true });
             }
@@ -220,11 +233,12 @@ export const DataTable = withStyles(styles)(
         initializeCheckedList = (uuids: any[]): void => {
             const newCheckedList = { ...this.props.checkedList };
 
-            uuids.forEach(uuid => {
-                if (!newCheckedList.hasOwnProperty(uuid)) {
-                    newCheckedList[uuid] = false;
+            if(Object.keys(newCheckedList).length === 0){
+                for(const uuid of uuids){
+                    newCheckedList[uuid] = false
                 }
-            });
+            }
+
             for (const key in newCheckedList) {
                 if (!uuids.includes(key)) {
                     delete newCheckedList[key];
@@ -412,7 +426,7 @@ export const DataTable = withStyles(styles)(
         );
 
         renderBodyRow = (item: any, index: number) => {
-            const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props;
+            const { onRowClick, onRowDoubleClick, extractKey, classes, selectedResourceUuid, currentRoute } = this.props;
             return (
                 <TableRow
                     data-cy={'data-table-row'}
@@ -421,7 +435,7 @@ export const DataTable = withStyles(styles)(
                     onClick={event => onRowClick && onRowClick(event, item)}
                     onContextMenu={this.handleRowContextMenu(item)}
                     onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
-                    selected={item === currentItemUuid}>
+                    selected={item === selectedResourceUuid}>
                     {this.mapVisibleColumns((column, index) => (
                         <TableCell
                             key={column.key || index}
diff --git a/services/workbench2/src/components/expand-chevron-right/expand-chevron-right.tsx b/services/workbench2/src/components/expand-chevron-right/expand-chevron-right.tsx
new file mode 100644 (file)
index 0000000..7bb0b14
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { ChevronRight } from '@material-ui/icons';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from 'common/custom-theme';
+
+type CssRules = 'root' | 'default' | 'expanded';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        width: '24px',
+        height: '24px',
+        cursor: 'pointer',
+    },
+    default: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(0deg)',
+    },
+    expanded: {
+        transition: 'all 0.1s ease',
+        transform: 'rotate(90deg)',
+    },
+});
+
+export interface ExpandChevronRightDataProps {
+    expanded: boolean;
+}
+
+type ExpandChevronRightProps = ExpandChevronRightDataProps & WithStyles<CssRules>;
+
+export const ExpandChevronRight = withStyles(styles)(
+    class extends React.Component<ExpandChevronRightProps, {}> {
+        render() {
+            const { classes, expanded } = this.props;
+            return (
+                <div className={classes.root}>
+                    <ChevronRight className={expanded ? classes.expanded : classes.default} />
+                </div>
+            );
+        }
+    }
+);
\ No newline at end of file
index 194950b134c9c0cf5d7f233909033b70ae16a01e..0d74b6b38ba40750d847134d55b4d7b252a5f946 100644 (file)
@@ -32,8 +32,9 @@ import { CollectionResource } from "models/collection";
 import { getProcess } from "store/processes/process";
 import { Process } from "store/processes/process";
 import { PublicFavoritesState } from "store/public-favorites/public-favorites-reducer";
-import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
+import { AuthState } from "store/auth/auth-reducer";
 import { IntersectionObserverWrapper } from "./ms-toolbar-overflow-wrapper";
+import classNames from "classnames";
 import { ContextMenuKind, sortMenuItems, menuDirection } from 'views-components/context-menu/menu-item-sort';
 
 type CssRules = "root" | "button" | "iconContainer" | "icon" | "divider";
@@ -43,7 +44,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         display: "flex",
         flexDirection: "row",
         width: 0,
-        height: '2.7rem',
+        height: '2.5rem',
         padding: 0,
         margin: "1rem auto auto 0.3rem",
         overflow: 'hidden',
@@ -68,11 +69,15 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 export type MultiselectToolbarProps = {
     checkedList: TCheckedList;
-    singleSelectedUuid: string | null
+    selectedResourceUuid: string | null;
     iconProps: IconProps
     user: User | null
     disabledButtons: Set<string>
-    executeMulti: (action: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
+    auth: AuthState;
+    location: string;
+    isSubPanel?: boolean;
+    injectedStyles?: string;
+    executeMulti: (action: ContextMenuAction | MultiSelectMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
 };
 
 type IconProps = {
@@ -81,13 +86,25 @@ type IconProps = {
     publicFavorites: PublicFavoritesState;
 }
 
+const disallowedPaths = [
+    "/favorites",
+    "/public-favorites",
+    "/trash",
+    "/group",
+]
+
+const isPathDisallowed = (location: string): boolean => {
+    return disallowedPaths.some(path => location.includes(path))
+}
+
 export const MultiselectToolbar = connect(
     mapStateToProps,
     mapDispatchToProps
 )(
     withStyles(styles)((props: MultiselectToolbarProps & WithStyles<CssRules>) => {
-        const { classes, checkedList, singleSelectedUuid, iconProps, user, disabledButtons } = props;
-        const singleResourceKind = singleSelectedUuid ? [resourceToMsResourceKind(singleSelectedUuid, iconProps.resources, user)] : null
+        const { classes, checkedList, iconProps, user, disabledButtons, location, isSubPanel, injectedStyles } = props;
+        const selectedResourceUuid = isPathDisallowed(location) ? null : props.selectedResourceUuid;
+        const singleResourceKind = selectedResourceUuid && !isSubPanel ? [resourceToMsResourceKind(selectedResourceUuid, iconProps.resources, user)] : null
         const currentResourceKinds = singleResourceKind ? singleResourceKind : Array.from(selectedToKindSet(checkedList));
         const currentPathIsTrash = window.location.pathname === "/trash";
 
@@ -95,7 +112,7 @@ export const MultiselectToolbar = connect(
             currentPathIsTrash && selectedToKindSet(checkedList).size
                 ? [msToggleTrashAction]
                 : selectActionsByKind(currentResourceKinds as string[], multiselectActionsFilters).filter((action) =>
-                        singleSelectedUuid === null ? action.isForMulti : true
+                        selectedResourceUuid === null ? action.isForMulti : true
                     );
                     
         const actions: ContextMenuAction[] | MultiSelectMenuAction[] = sortMenuItems(
@@ -104,6 +121,8 @@ export const MultiselectToolbar = connect(
             menuDirection.HORIZONTAL
         ); 
 
+        const targetResources = selectedResourceUuid ? {[selectedResourceUuid]: true} as TCheckedList : checkedList
+
         return (
             <React.Fragment>
                 <Toolbar
@@ -129,7 +148,7 @@ export const MultiselectToolbar = connect(
                                 <Tooltip
                                     className={classes.button}
                                     data-targetid={name}
-                                    title={currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altName : name}
+                                    title={currentPathIsTrash || (useAlts && useAlts(selectedResourceUuid, iconProps)) ? altName : name}
                                     key={i}
                                     disableFocusListener
                                 >
@@ -137,10 +156,10 @@ export const MultiselectToolbar = connect(
                                         <IconButton
                                             data-cy='multiselect-button'
                                             disabled={disabledButtons.has(name)}
-                                            onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                            onClick={() => props.executeMulti(action, targetResources, iconProps.resources)}
                                             className={classes.icon}
                                         >
-                                            {currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altIcon && altIcon({}) : icon({})}
+                                            {currentPathIsTrash || (useAlts && useAlts(selectedResourceUuid, iconProps)) ? altIcon && altIcon({}) : icon({})}
                                         </IconButton>
                                     </span>
                                 </Tooltip>
@@ -155,7 +174,8 @@ export const MultiselectToolbar = connect(
                                     <span className={classes.iconContainer}>
                                         <IconButton
                                             data-cy='multiselect-button'
-                                            onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                            onClick={() => {
+                                                props.executeMulti(action, targetResources, iconProps.resources)}}
                                             className={classes.icon}
                                         >
                                             {action.icon({})}
@@ -214,7 +234,7 @@ const resourceToMsResourceKind = (uuid: string, resources: ResourcesState, user:
     const { isAdmin } = user;
     const kind = extractUuidKind(uuid);
 
-    const isFrozen = resourceIsFrozen(resource, resources);
+    const isFrozen = resource?.kind && resource.kind === ResourceKind.PROJECT ? resourceIsFrozen(resource, resources) : false;
     const isEditable = (user.isAdmin || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
 
     switch (kind) {
@@ -259,7 +279,7 @@ const resourceToMsResourceKind = (uuid: string, resources: ResourcesState, user:
                 ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
                 : ContextMenuKind.PROCESS_RESOURCE;
         case ResourceKind.USER:
-            return ContextMenuKind.ROOT_PROJECT;
+            return isAdmin ? ContextMenuKind.ROOT_PROJECT_ADMIN : ContextMenuKind.ROOT_PROJECT;
         case ResourceKind.LINK:
             return ContextMenuKind.LINK;
         case ResourceKind.WORKFLOW:
@@ -305,12 +325,14 @@ function selectActionsByKind(currentResourceKinds: Array<string>, filterSet: TMu
 
 //--------------------------------------------------//
 
-function mapStateToProps({auth, multiselect, resources, favorites, publicFavorites}: RootState) {
+function mapStateToProps({auth, multiselect, resources, favorites, publicFavorites, selectedResourceUuid}: RootState) {
     return {
         checkedList: multiselect.checkedList as TCheckedList,
-        singleSelectedUuid: isExactlyOneSelected(multiselect.checkedList),
         user: auth && auth.user ? auth.user : null,
         disabledButtons: new Set<string>(multiselect.disabledButtons),
+        auth,
+        selectedResourceUuid,
+        location: window.location.pathname,
         iconProps: {
             resources,
             favorites,
@@ -323,6 +345,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
     return {
         executeMulti: (selectedAction: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState): void => {
             const kindGroups = groupByKind(checkedList, resources);
+            const currentList = selectedToArray(checkedList)
             switch (selectedAction.name) {
                 case ContextMenuActionNames.MOVE_TO:
                 case ContextMenuActionNames.REMOVE:
index 5a84d4c573f711a46a4a4665b9acbcff5bf2f18f..1e49ae664fe9d8b6ae7427f12827a5edff1fccb1 100644 (file)
@@ -8,16 +8,18 @@ import { msCollectionActionSet } from "views-components/multiselect-toolbar/ms-c
 import { msProjectActionSet } from "views-components/multiselect-toolbar/ms-project-action-set";
 import { msProcessActionSet } from "views-components/multiselect-toolbar/ms-process-action-set";
 import { msWorkflowActionSet } from "views-components/multiselect-toolbar/ms-workflow-action-set";
+import { UserDetailsActionSet } from "views-components/multiselect-toolbar/ms-user-details-action-set";
 
 export function findActionByName(name: string, actionSet: MultiSelectMenuActionSet) {
     return actionSet[0].find(action => action.name === name);
 }
 
-const { COLLECTION, PROCESS, PROJECT, WORKFLOW } = ResourceKind;
+const { COLLECTION, PROCESS, PROJECT, WORKFLOW, USER } = ResourceKind;
 
 export const kindToActionSet: Record<string, MultiSelectMenuActionSet> = {
     [COLLECTION]: msCollectionActionSet,
     [PROCESS]: msProcessActionSet,
     [PROJECT]: msProjectActionSet,
     [WORKFLOW]: msWorkflowActionSet,
+    [USER]: UserDetailsActionSet,
 };
index 2b30525e56499c95216b24d9eb0176d560149f6a..9af91daa531ce8ebfbc19b491fafdb78043f3291 100644 (file)
@@ -15,6 +15,7 @@ import {
 } from 'views-components/multiselect-toolbar/ms-project-action-set';
 import { msProcessActionSet, msCommonProcessActionFilter, msAdminProcessActionFilter, msRunningProcessActionFilter } from 'views-components/multiselect-toolbar/ms-process-action-set';
 import { msWorkflowActionSet, msWorkflowActionFilter, msReadOnlyWorkflowActionFilter } from 'views-components/multiselect-toolbar/ms-workflow-action-set';
+import { UserDetailsActionSet } from 'views-components/multiselect-toolbar/ms-user-details-action-set';
 import { ResourceKind } from 'models/resource';
 import { ContextMenuKind } from 'views-components/context-menu/menu-item-sort';
 
@@ -27,7 +28,9 @@ const {
     RUNNING_PROCESS_ADMIN,
     PROCESS_ADMIN,
     PROJECT,
+    ROOT_PROJECT,
     PROJECT_ADMIN,
+    ROOT_PROJECT_ADMIN,
     FROZEN_PROJECT,
     FROZEN_PROJECT_ADMIN,
     READONLY_PROJECT,
@@ -65,4 +68,7 @@ export const multiselectActionsFilters: TMultiselectActionsFilters = {
     
     [WORKFLOW]: [msWorkflowActionSet, msWorkflowActionFilter],
     [READONLY_WORKFLOW]: [msWorkflowActionSet, msReadOnlyWorkflowActionFilter],
+
+    [ROOT_PROJECT]: [UserDetailsActionSet, allActionNames(UserDetailsActionSet)],
+    [ROOT_PROJECT_ADMIN]: [UserDetailsActionSet, allActionNames(UserDetailsActionSet)],
 };
index e0f32f1fa6e7a8f8a9b6a3ca45e2661b3a5d1272..5c1c433712a718230b2476604e90bd31be49ff5e 100644 (file)
@@ -46,9 +46,8 @@ export const IntersectionObserverWrapper = withStyles(styles)((props: WrapperPro
     const navRef = useRef<any>(null);
     const [visibilityMap, setVisibilityMap] = useState<Record<string, boolean>>({});
     const [numHidden, setNumHidden] = useState(() => findNumHidden(visibilityMap));
-
     const prevNumHidden = useRef(numHidden);
-
+    
     const handleIntersection = (entries) => {
         const updatedEntries: Record<string, boolean> = {};
         entries.forEach((entry) => {
index 400b975d4d7de73a47cc91f41db43fb367591aee..784d1ffdb024757df531df3fcf25ac710b0d785c 100644 (file)
@@ -77,6 +77,7 @@ import { keepServiceActionSet } from "views-components/context-menu/action-sets/
 import { loadVocabulary } from "store/vocabulary/vocabulary-actions";
 import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set";
 import { userActionSet } from "views-components/context-menu/action-sets/user-action-set";
+import { UserDetailsActionSet } from "views-components/context-menu/action-sets/user-details-action-set";
 import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set";
 import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set";
 import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set";
@@ -125,6 +126,7 @@ addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
 addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
 addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
 addMenuActionSet(ContextMenuKind.USER, userActionSet);
+addMenuActionSet(ContextMenuKind.USER_DETAILS, UserDetailsActionSet);
 addMenuActionSet(ContextMenuKind.LINK, linkActionSet);
 addMenuActionSet(ContextMenuKind.API_CLIENT_AUTHORIZATION, apiClientAuthorizationActionSet);
 addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
index b6eabd7014efade5dea3c51775f876413573d0bb..5bde295e9a6b85e76165c8799ec58a5ea03c5ef1 100644 (file)
@@ -8,5 +8,6 @@ import { ProcessResource } from "./process";
 import { EmptyResource } from "./empty";
 import { CollectionFile, CollectionDirectory } from 'models/collection-file';
 import { WorkflowResource } from 'models/workflow';
+import { UserResource } from "./user";
 
-export type DetailsResource = ProjectResource | CollectionResource | ProcessResource | EmptyResource | CollectionFile | CollectionDirectory | WorkflowResource;
+export type DetailsResource = ProjectResource | CollectionResource | ProcessResource | EmptyResource | CollectionFile | CollectionDirectory | WorkflowResource | UserResource & {name?: string};
index e14c70ace72fc10d74defb7b6f27ec6e1378e01b..ca2db7217f25e77075ff38377a406184f20ab906 100644 (file)
@@ -15,6 +15,7 @@ import { CollectionResource } from 'models/collection';
 import { extractUuidKind, ResourceKind } from 'models/resource';
 
 export const SLIDE_TIMEOUT = 500;
+export const CLOSE_DRAWER = 'CLOSE_DRAWER'
 
 export const detailsPanelActions = unionize({
     TOGGLE_DETAILS_PANEL: ofType<{}>(),
@@ -66,20 +67,26 @@ export const refreshCollectionVersionsList = (uuid: string) =>
         );
     };
 
-export const toggleDetailsPanel = () => (dispatch: Dispatch, getState: () => RootState) => {
-    // because of material-ui issue resizing details panel breaks tabs.
-    // triggering window resize event fixes that.
-    setTimeout(() => {
-        window.dispatchEvent(new Event('resize'));
-    }, SLIDE_TIMEOUT);
-    startDetailsPanelTransition(dispatch)
-    dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
-    if (getState().detailsPanel.isOpened) {
-        dispatch<any>(loadDetailsPanel(getState().detailsPanel.resourceUuid));
+export const toggleDetailsPanel = (uuid: string = '') => (dispatch: Dispatch, getState: () => RootState) => {
+    const { detailsPanel }= getState()
+    const isTargetUuidNew = uuid !== detailsPanel.resourceUuid
+    if(isTargetUuidNew && uuid !== CLOSE_DRAWER && detailsPanel.isOpened){
+        dispatch<any>(loadDetailsPanel(uuid));
+    } else {
+        // because of material-ui issue resizing details panel breaks tabs.
+        // triggering window resize event fixes that.
+        setTimeout(() => {
+            window.dispatchEvent(new Event('resize'));
+        }, SLIDE_TIMEOUT);
+        startDetailsPanelTransition(dispatch)
+        dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+        if (getState().detailsPanel.isOpened) {
+            dispatch<any>(loadDetailsPanel(isTargetUuidNew ? uuid : detailsPanel.resourceUuid));
+        }
     }
-};
-
-const startDetailsPanelTransition = (dispatch) => {
+    };
+    
+    const startDetailsPanelTransition = (dispatch) => {
         dispatch(detailsPanelActions.START_TRANSITION())
     setTimeout(() => {
         dispatch(detailsPanelActions.END_TRANSITION())
index b488932f0bbbcfa1272fcc666ddaf751e727054a..b73a4be4bb006f0ba073206d973e051a973824c7 100644 (file)
@@ -5,17 +5,15 @@
 import { multiselectActionConstants } from "./multiselect-actions";
 import { TCheckedList } from "components/data-table/data-table";
 
-type MultiselectToolbarState = {
+export type MultiselectToolbarState = {
     isVisible: boolean;
     checkedList: TCheckedList;
-    selectedUuid: string;
     disabledButtons: string[];
 };
 
 const multiselectToolbarInitialState = {
     isVisible: false,
     checkedList: {},
-    selectedUuid: '',
     disabledButtons: []
 };
 
@@ -33,7 +31,7 @@ const toggleOneCheck = (inputList: TCheckedList, uuid: string)=>{
     return { ...inputList, [uuid]: (checkedlist[uuid] && checkedlist[uuid] === true) && isOnlyOneSelected ? false : true };
 }
 
-const { TOGGLE_VISIBLITY, SET_CHECKEDLIST, SELECT_ONE, DESELECT_ONE, DESELECT_ALL_OTHERS, TOGGLE_ONE, SET_SELECTED_UUID, ADD_DISABLED, REMOVE_DISABLED } = multiselectActionConstants;
+const { TOGGLE_VISIBLITY, SET_CHECKEDLIST, SELECT_ONE, DESELECT_ONE, DESELECT_ALL_OTHERS, TOGGLE_ONE, ADD_DISABLED, REMOVE_DISABLED } = multiselectActionConstants;
 
 export const multiselectReducer = (state: MultiselectToolbarState = multiselectToolbarInitialState, action) => {
     switch (action.type) {
@@ -49,8 +47,6 @@ export const multiselectReducer = (state: MultiselectToolbarState = multiselectT
             return { ...state, checkedList: uncheckAllOthers(state.checkedList, action.payload) };
         case TOGGLE_ONE:
             return { ...state, checkedList: toggleOneCheck(state.checkedList, action.payload) };
-        case SET_SELECTED_UUID:
-            return {...state, selectedUuid: action.payload || ''}
         case ADD_DISABLED:
             return { ...state, disabledButtons: [...state.disabledButtons, action.payload]}
         case REMOVE_DISABLED:
index e8d03dfcd76ef6df186a9ab78acc87b692e09178..61c89cd6a78ea76331fbb89aad8aeac9fddf7c47 100644 (file)
@@ -58,7 +58,7 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                 api.dispatch<any>(dataExplorerActions.SET_IS_NOT_FOUND({ id: this.id, isNotFound: false }));
                 if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
                 const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer, !!isProjectTrashed));
-                const resourceUuids = response.items.map(item => item.uuid);
+                const resourceUuids = [...response.items.map(item => item.uuid), projectUuid];
                 api.dispatch<any>(updateFavorites(resourceUuids));
                 api.dispatch<any>(updatePublicFavorites(resourceUuids));
                 api.dispatch(updateResources(response.items));
index cd72e351964f0d110d95127459e90d8d972a862f..84cea4380954e226be50a8665ce695023fc32c4a 100644 (file)
@@ -9,21 +9,29 @@ import { loadResource } from "store/resources/resources-actions";
 import { RootState } from "store/store";
 import { ContextMenuActionNames } from "views-components/context-menu/context-menu-action-set"; 
 import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export const freezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     dispatch<any>(addDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
     const userUUID = getState().auth.user!.uuid;
-    
-    const updatedProject = await services.projectService.update(uuid, {
-        frozenByUuid: userUUID,
-    });
-    
+    let updatedProject;
+
+    try {
+        updatedProject = await services.projectService.update(uuid, {
+            frozenByUuid: userUUID,
+        });
+    } catch (e) {
+        console.error(e);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not freeze project', hideDuration: 4000, kind: SnackbarKind.ERROR }));
+    }
+
     dispatch(projectPanelActions.REQUEST_ITEMS());
     dispatch<any>(loadResource(uuid, false));
     dispatch<any>(removeDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
     return updatedProject;
 };
 
+
 export const unfreezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     dispatch<any>(addDisabledButton(ContextMenuActionNames.FREEZE_PROJECT))
     const updatedProject = await services.projectService.update(uuid, {
diff --git a/services/workbench2/src/store/selected-resource/selected-resource-actions.ts b/services/workbench2/src/store/selected-resource/selected-resource-actions.ts
new file mode 100644 (file)
index 0000000..36f9222
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const selectedResourceActions = {
+    SET_SELECTED_RESOURCE: 'SET_SELECTED_RESOURCE',
+}
+
+type SelectedResourceAction = {
+    type: string;
+    payload: string | null;
+};
+
+export const setSelectedResourceUuid = (resourceUuid: string | null): SelectedResourceAction => ({
+    type: selectedResourceActions.SET_SELECTED_RESOURCE,
+    payload: resourceUuid
+});
diff --git a/services/workbench2/src/store/selected-resource/selected-resource-reducer.ts b/services/workbench2/src/store/selected-resource/selected-resource-reducer.ts
new file mode 100644 (file)
index 0000000..502fa26
--- /dev/null
@@ -0,0 +1,14 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { selectedResourceActions } from "./selected-resource-actions";
+
+type SelectedResourceState = string | null;
+
+export const selectedResourceReducer = (state: SelectedResourceState = null, action: any) => {
+    if (action.type === selectedResourceActions.SET_SELECTED_RESOURCE) {
+        return action.payload;
+    }
+    return state;
+};
\ No newline at end of file
index ee861f18be4c7eb4253dc9a6005128bced3df6b5..4033166bb93c5e51921c6186e7d0e2b8821bce0b 100644 (file)
@@ -81,6 +81,7 @@ import { sidePanelReducer } from "./side-panel/side-panel-reducer";
 import { bannerReducer } from "./banner/banner-reducer";
 import { multiselectReducer } from "./multiselect/multiselect-reducer";
 import { composeWithDevTools } from "redux-devtools-extension";
+import { selectedResourceReducer } from "./selected-resource/selected-resource-reducer";
 
 declare global {
     interface Window {
@@ -186,6 +187,7 @@ const createRootReducer = (services: ServiceRepository) =>
         properties: propertiesReducer,
         resources: resourcesReducer,
         router: routerReducer,
+        selectedResourceUuid: selectedResourceReducer,
         snackbar: snackbarReducer,
         treePicker: treePickerReducer,
         treePickerSearch: treePickerSearchReducer,
index 4c789dbeede4fda3f97f61b6ed32286f120a5d2c..60b81b7ba4ec14c4c9360ff9c82f915735c27c05 100644 (file)
@@ -136,16 +136,6 @@ export const openUserPanel = () =>
         }
     };
 
-export const toggleIsAdmin = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const data = getResource<UserResource>(uuid)(resources);
-        const isAdmin = data!.isAdmin;
-        const newActivity = await services.userService.update(uuid, { isAdmin: !isAdmin });
-        dispatch<any>(loadUsersPanel());
-        return newActivity;
-    };
-
 export const loadUsersPanel = () =>
     (dispatch: Dispatch) => {
         dispatch(userBindedActions.RESET_EXPLORER_SEARCH_VALUE());
@@ -156,6 +146,7 @@ export enum UserAccountStatus {
         ACTIVE = 'Active',
         INACTIVE = 'Inactive',
         SETUP = 'Setup',
+        OTHER = 'Other',
     }
 
 export const getUserAccountStatus = (state: RootState, uuid: string) => {
diff --git a/services/workbench2/src/views-components/context-menu/action-sets/user-details-action-set.ts b/services/workbench2/src/views-components/context-menu/action-sets/user-details-action-set.ts
new file mode 100644 (file)
index 0000000..eb9d888
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, AttributesIcon, UserPanelIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openUserAttributes } from 'store/users/users-actions';
+import { navigateToUserProfile } from 'store/navigation/navigation-action';
+import { needsUserProfileLink } from 'store/context-menu/context-menu-filters';
+
+export const UserDetailsActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'User Account',
+            icon: UserPanelIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(navigateToUserProfile(resources[0].uuid));
+            },
+            filters: [needsUserProfileLink],
+        },
+    ],
+];
index 643949a20e0545169dac1aa6359705ccc6b86919..034399b124765d9c1af4bb3aa7410a128a699829 100644 (file)
@@ -12,6 +12,7 @@ import { DataColumn } from "components/data-table/data-column";
 import { DataColumns, TCheckedList } from "components/data-table/data-table";
 import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
 import { toggleMSToolbar, setCheckedListOnStore } from "store/multiselect/multiselect-actions";
+import { setSelectedResourceUuid } from "store/selected-resource/selected-resource-actions";
 
 interface Props {
     id: string;
@@ -26,16 +27,12 @@ const mapStateToProps = ({ progressIndicator, dataExplorer, router, multiselect,
     const working = !!progressIndicator.some(p => p.id === id && p.working);
     const dataExplorerState = getDataExplorer(dataExplorer, id);
     const currentRoute = router.location ? router.location.pathname : "";
-    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
-    const isOnlyOneSelected = Object.values(multiselect.checkedList).filter(x => x === true).length === 1;
-    const currentItemUuid =
-        currentRoute === '/workflows' ? properties.workflowPanelDetailsUuid : isDetailsResourceChecked && isOnlyOneSelected ? detailsPanel.resourceUuid : multiselect.selectedUuid;
     const isMSToolbarVisible = multiselect.isVisible;
     return {
         ...dataExplorerState,
         currentRoute: currentRoute,
         paperKey: currentRoute,
-        currentItemUuid,
+        currentRouteUuid: properties.currentRouteUuid,
         isMSToolbarVisible,
         checkedList: multiselect.checkedList,
         working,
@@ -84,6 +81,10 @@ const mapDispatchToProps = () => {
             dispatch<any>(setCheckedListOnStore(checkedList));
         },
 
+        setSelectedUuid: (uuid: string | null) => {
+            dispatch<any>(setSelectedResourceUuid(uuid));
+        },
+        
         onRowClick,
 
         onRowDoubleClick,
index 91b06c2b2f0a9e22d0d407422d4c2b0fc4636729..bb49b6cf42e5cc25b9a06647d301a8562e92d7dc 100644 (file)
@@ -37,7 +37,6 @@ import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-
 import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions";
 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user";
-import { toggleIsAdmin } from "store/users/users-actions";
 import { LinkClass, LinkResource } from "models/link";
 import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action";
 import { withResourceData } from "views-components/data-explorer/with-resources";
@@ -54,6 +53,18 @@ import { VirtualMachinesResource } from "models/virtual-machines";
 import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar";
 import { ProjectResource } from "models/project";
 import { ProcessResource } from "models/process";
+import { ServiceRepository } from "services/services";
+import { loadUsersPanel } from "store/users/users-actions";
+
+export const toggleIsAdmin = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<UserResource>(uuid)(resources);
+        const isAdmin = data!.isAdmin;
+        const newActivity = await services.userService.update(uuid, { isAdmin: !isAdmin });
+        dispatch<any>(loadUsersPanel());
+        return newActivity;
+    };
 import { InlinePulser } from "components/loading/inline-pulser";
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
@@ -90,7 +101,7 @@ const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
     );
 };
 
-const FrozenProject = (props: { item: ProjectResource }) => {
+export const FrozenProject = (props: { item: ProjectResource }) => {
     const [fullUsername, setFullusername] = React.useState<any>(null);
     const getFullName = React.useCallback(() => {
         if (props.item.frozenByUuid) {
index 2653a2103345fe40a99cf9deb467e162efb05572..eb6e65d64579f2b8b6df59dab853029db83c4016 100644 (file)
@@ -15,6 +15,7 @@ import { EmptyResource } from 'models/empty';
 import { Dispatch } from "redux";
 import { ResourceKind } from "models/resource";
 import { ProjectDetails } from "./project-details";
+import { RootProjectDetails } from './root-project-details';
 import { CollectionDetails } from "./collection-details";
 import { ProcessDetails } from "./process-details";
 import { EmptyDetails } from "./empty-details";
@@ -28,6 +29,7 @@ import { toggleDetailsPanel, SLIDE_TIMEOUT, openDetailsPanel } from 'store/detai
 import { FileDetails } from 'views-components/details-panel/file-details';
 import { getNode } from 'models/tree';
 import { resourceIsFrozen } from 'common/frozen-resources';
+import { CLOSE_DRAWER } from 'store/details-panel/details-panel-action';
 
 type CssRules = 'root' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
 
@@ -64,7 +66,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 const EMPTY_RESOURCE: EmptyResource = { kind: undefined, name: 'Projects' };
 
-const getItem = (res: DetailsResource): DetailsData => {
+const getItem = (res: DetailsResource, pathName: string): DetailsData => {
     if ('kind' in res) {
         switch (res.kind) {
             case ResourceKind.PROJECT:
@@ -75,19 +77,21 @@ const getItem = (res: DetailsResource): DetailsData => {
                 return new ProcessDetails(res);
             case ResourceKind.WORKFLOW:
                 return new WorkflowDetails(res);
+            case ResourceKind.USER:
+                if(pathName.includes('projects')) {
+                    return new RootProjectDetails(res);
+                }
+                return new EmptyDetails(EMPTY_RESOURCE);
             default:
-                return new EmptyDetails(res);
+                return new EmptyDetails(res as EmptyResource);
         }
     } else {
         return new FileDetails(res);
     }
 };
 
-const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles, multiselect, router }: RootState) => {
-    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
-    const currentRoute = router.location ? router.location.pathname : "";
-    const currentItemUuid = isDetailsResourceChecked || currentRoute.includes('collections') ? detailsPanel.resourceUuid : multiselect.selectedUuid ? multiselect.selectedUuid : currentRoute.split('/')[2];
-    const resource = getResource(currentItemUuid)(resources) as DetailsResource | undefined;
+const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles, selectedResourceUuid, properties, router }: RootState) => {
+    const resource = getResource(selectedResourceUuid ?? properties.currentRouteUuid)(resources) as DetailsResource | undefined;
     const file = resource
         ? undefined
         : getNode(detailsPanel.resourceUuid)(collectionPanelFiles);
@@ -103,12 +107,13 @@ const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles,
         isOpened: detailsPanel.isOpened,
         tabNr: detailsPanel.tabNr,
         res: resource || (file && file.value) || EMPTY_RESOURCE,
+        pathname: router.location ? router.location.pathname : "",
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
-    onCloseDrawer: () => {
-        dispatch<any>(toggleDetailsPanel());
+    onCloseDrawer: (currentItemId) => {
+        dispatch<any>(toggleDetailsPanel(currentItemId));
     },
     setActiveTab: (tabNr: number) => {
         dispatch<any>(openDetailsPanel(undefined, tabNr));
@@ -116,13 +121,14 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
 });
 
 export interface DetailsPanelDataProps {
-    onCloseDrawer: () => void;
+    onCloseDrawer: (currentItemId) => void;
     setActiveTab: (tabNr: number) => void;
     authConfig: Config;
     isOpened: boolean;
     tabNr: number;
     res: DetailsResource;
     isFrozen: boolean;
+    pathname: string;
 }
 
 type DetailsPanelProps = DetailsPanelDataProps & WithStyles<CssRules>;
@@ -162,8 +168,7 @@ export const DetailsPanel = withStyles(styles)(
             }
 
             renderContent() {
-                const { classes, onCloseDrawer, res, tabNr, authConfig } = this.props;
-
+                const { classes, onCloseDrawer, res, tabNr, authConfig, pathname } = this.props;
                 let shouldShowInlinePreview = false;
                 if (!('kind' in res)) {
                     shouldShowInlinePreview = isInlineFileUrlSafe(
@@ -173,7 +178,7 @@ export const DetailsPanel = withStyles(styles)(
                     ) || authConfig.clusterConfig.Collections.TrustAllContent;
                 }
 
-                const item = getItem(res);
+                const item = getItem(res, pathname);
                 return <Grid
                     data-cy='details-panel'
                     container
@@ -199,7 +204,7 @@ export const DetailsPanel = withStyles(styles)(
                             </Tooltip>
                         </Grid>
                         <Grid item>
-                            <IconButton color="inherit" onClick={onCloseDrawer}>
+                            <IconButton color="inherit" onClick={()=>onCloseDrawer(CLOSE_DRAWER)}>
                                 <CloseIcon />
                             </IconButton>
                         </Grid>
diff --git a/services/workbench2/src/views-components/details-panel/root-project-details.tsx b/services/workbench2/src/views-components/details-panel/root-project-details.tsx
new file mode 100644 (file)
index 0000000..58c046b
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { ProjectsIcon } from 'components/icon/icon';
+import { formatDate } from 'common/formatters';
+import { DetailsData } from "./details-data";
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { Dispatch } from 'redux';
+import { openProjectUpdateDialog, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
+import { RootState } from 'store/store';
+import { ResourcesState } from 'store/resources/resources';
+import { UserResource } from 'models/user';
+import { UserResourceFullName } from 'views-components/data-explorer/renderers';
+
+export class RootProjectDetails extends DetailsData<UserResource> {
+    getIcon(className?: string) {
+        return <ProjectsIcon className={className} />;
+    }
+
+    getDetails() {
+        return <RootProjectDetailsComponent rootProject={this.item} />;
+    }
+}
+
+type CssRules = 'tag' | 'editIcon' | 'editButton';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2,
+    },
+    editIcon: {
+        paddingRight: theme.spacing.unit / 2,
+        fontSize: '1.125rem',
+    },
+    editButton: {
+        boxShadow: 'none',
+        padding: '2px 10px 2px 5px',
+        fontSize: '0.75rem'
+    },
+});
+
+interface RootProjectDetailsComponentDataProps {
+    rootProject: any;
+}
+
+const mapStateToProps = (state: RootState): { resources: ResourcesState } => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onClick: (prj: ProjectUpdateFormDialogData) =>
+        () => dispatch<any>(openProjectUpdateDialog(prj)),
+});
+
+type RootProjectDetailsComponentProps = RootProjectDetailsComponentDataProps & WithStyles<CssRules>;
+
+const RootProjectDetailsComponent = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+        ({ rootProject}: RootProjectDetailsComponentProps & { resources: ResourcesState }) => <div>
+            <DetailsAttribute label='Type' value="Root Project" />
+            <DetailsAttribute label='User' />
+            <UserResourceFullName uuid={rootProject.uuid} link={true} />
+            <DetailsAttribute label='Created at' value={formatDate(rootProject.createdAt)} />
+            <DetailsAttribute label='UUID' linkToUuid={rootProject.uuid} value={rootProject.uuid} />
+        </div>
+    ));
index 3f4de301f2b465e59ea94e01c25116a23bba9257..5999b4855afa057c48a7e603cea5addbd383f4cc 100644 (file)
@@ -34,6 +34,7 @@ interface MainContentBarProps {
     onRefreshPage: () => void;
     onDetailsPanelToggle: () => void;
     buttonVisible: boolean;
+    projectUuid: string;
 }
 
 const isButtonVisible = ({ router }: RootState) => {
@@ -53,13 +54,18 @@ const isButtonVisible = ({ router }: RootState) => {
         Routes.matchFavoritesRoute(pathname);
 };
 
-const mapStateToProps = (state: RootState) => ({
-    buttonVisible: isButtonVisible(state),
-    projectUuid: state.detailsPanel.resourceUuid,
-});
+const mapStateToProps = (state: RootState) => {
+    const currentRoute = state.router.location?.pathname.split('/') || [];
+    const projectUuid = currentRoute[currentRoute.length - 1];
+
+    return {
+        buttonVisible: isButtonVisible(state),
+        projectUuid,
+    }
+};
 
 const mapDispatchToProps = () => (dispatch: Dispatch) => ({
-    onDetailsPanelToggle: () => dispatch<any>(toggleDetailsPanel()),
+    onDetailsPanelToggle: (uuid: string) => dispatch<any>(toggleDetailsPanel(uuid)),
     onRefreshButtonClick: (id) => {
         dispatch<any>(loadSidePanelTreeProjects(id));
     }
@@ -77,11 +83,11 @@ export const MainContentBar = connect(mapStateToProps, mapDispatchToProps)(withS
                 }} />
             </Grid>
             <Grid item>
-                {props.buttonVisible && <Tooltip title="Additional Info">
+                {props.buttonVisible && <Tooltip title="Additional Info" disableFocusListener>
                     <IconButton data-cy="additional-info-icon"
                         color="inherit"
                         className={props.classes.infoTooltip}
-                        onClick={props.onDetailsPanelToggle}>
+                        onClick={()=>props.onDetailsPanelToggle(props.projectUuid)}>
                         <DetailsIcon />
                     </IconButton>
                 </Tooltip>}
index 12840cdea2416daab65cc168a08905e49f2572dc..d129a8c6625654ecbd35e214648f1ad8dffbbab3 100644 (file)
@@ -67,8 +67,8 @@ const msViewDetailsAction: MultiSelectMenuAction  = {
     icon: DetailsIcon,
     hasAlts: false,
     isForMulti: false,
-    execute: (dispatch) => {
-        dispatch<any>(toggleDetailsPanel());
+    execute: (dispatch, resources) => {
+        dispatch<any>(toggleDetailsPanel(resources[0].uuid));
     },
 };
 
diff --git a/services/workbench2/src/views-components/multiselect-toolbar/ms-user-details-action-set.ts b/services/workbench2/src/views-components/multiselect-toolbar/ms-user-details-action-set.ts
new file mode 100644 (file)
index 0000000..3369825
--- /dev/null
@@ -0,0 +1,41 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AdvancedIcon, AttributesIcon, UserPanelIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openUserAttributes } from 'store/users/users-actions';
+import { navigateToUserProfile } from 'store/navigation/navigation-action';
+import { MultiSelectMenuActionSet, MultiSelectMenuActionNames } from './ms-menu-actions';
+
+export const UserDetailsActionSet: MultiSelectMenuActionSet= [
+    [
+        {
+            name: MultiSelectMenuActionNames.ATTRIBUTES,
+            icon: AttributesIcon,
+            hasAlts: false,
+            isForMulti: false,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: MultiSelectMenuActionNames.API_DETAILS,
+            icon: AdvancedIcon,
+            hasAlts: false,
+            isForMulti: false,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: MultiSelectMenuActionNames.USER_ACCOUNT,
+            icon: UserPanelIcon,
+            hasAlts: false,
+            isForMulti: false,
+            execute: (dispatch, resources) => {
+                dispatch<any>(navigateToUserProfile(resources[0].uuid));
+            },
+        },
+    ],
+];
diff --git a/services/workbench2/src/views-components/project-details-card/project-details-card.tsx b/services/workbench2/src/views-components/project-details-card/project-details-card.tsx
new file mode 100644 (file)
index 0000000..49884e4
--- /dev/null
@@ -0,0 +1,380 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, Card, CardHeader, WithStyles, withStyles, Typography, CardContent, Tooltip, Collapse, Grid } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { getResource } from 'store/resources/resources';
+import { getPropertyChip } from '../resource-properties-form/property-chip';
+import { ProjectResource } from 'models/project';
+import { ResourceKind } from 'models/resource';
+import { UserResource } from 'models/user';
+import { UserResourceAccountStatus } from 'views-components/data-explorer/renderers';
+import { FavoriteStar, PublicFavoriteStar } from 'views-components/favorite-star/favorite-star';
+import { FreezeIcon } from 'components/icon/icon';
+import { Resource } from 'models/resource';
+import { Dispatch } from 'redux';
+import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { ExpandChevronRight } from 'components/expand-chevron-right/expand-chevron-right';
+import { MultiselectToolbar } from 'components/multiselect-toolbar/MultiselectToolbar';
+import { setSelectedResourceUuid } from 'store/selected-resource/selected-resource-actions';
+import { deselectAllOthers } from 'store/multiselect/multiselect-actions';
+
+type CssRules =
+    | 'root'
+    | 'cardHeaderContainer'
+    | 'cardHeader'
+    | 'projectToolbar'
+    | 'descriptionToggle'
+    | 'showMore'
+    | 'noDescription'
+    | 'userNameContainer'
+    | 'cardContent'
+    | 'nameSection'
+    | 'namePlate'
+    | 'faveIcon'
+    | 'frozenIcon'
+    | 'accountStatusSection'
+    | 'chipSection'
+    | 'tag'
+    | 'description';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        marginBottom: '1rem',
+        flex: '0 0 auto',
+        padding: 0,
+        minHeight: '3rem',
+    },
+    showMore: {
+        cursor: 'pointer',
+    },
+    noDescription: {
+        color: theme.palette.grey['600'],
+        fontStyle: 'italic',
+        padding: '0  0 0.5rem 1rem',
+        marginTop: '-0.5rem',
+    },
+    userNameContainer: {
+        display: 'flex',
+        alignItems: 'center',
+        minHeight: '2.7rem',
+    },
+    cardHeaderContainer: {
+        width: '100%',
+        display: 'flex',
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+    },
+    cardHeader: {
+        minWidth: '30rem',
+        padding: '0.2rem 0.4rem 0.2rem 1rem',
+    },
+    projectToolbar: {
+        //shows only the first 3 buttons
+        width: '12rem !important',
+    },
+    descriptionToggle: {
+        display: 'flex',
+        flexDirection: 'row',
+        cursor: 'pointer',
+        paddingBottom: '0.5rem',
+    },
+    cardContent: {
+        display: 'flex',
+        flexDirection: 'column',
+        paddingTop: 0,
+        paddingLeft: '0.1rem',
+    },
+    nameSection: {
+        display: 'flex',
+        flexDirection: 'row',
+        alignItems: 'center',
+    },
+    namePlate: {
+        display: 'flex',
+        flexDirection: 'row',
+        alignItems: 'center',
+        margin: 0,
+        minHeight: '2.7rem',
+    },
+    faveIcon: {
+        fontSize: '0.8rem',
+        margin: 'auto 0 1rem 0.3rem',
+        color: theme.palette.text.primary,
+    },
+    frozenIcon: {
+        fontSize: '0.5rem',
+        marginLeft: '0.3rem',
+        height: '1rem',
+        color: theme.palette.text.primary,
+    },
+    accountStatusSection: {
+        display: 'flex',
+        flexDirection: 'row',
+        alignItems: 'center',
+        paddingLeft: '1rem',
+    },
+    chipSection: {
+        marginBottom: '-2rem',
+    },
+    tag: {
+        marginRight: '0.75rem',
+        marginBottom: '0.5rem',
+    },
+    description: {
+        maxWidth: '95%',
+        marginTop: 0,
+    },
+});
+
+const mapStateToProps = ({ auth, selectedResourceUuid, resources, properties }: RootState) => {
+    const currentResource = getResource(properties.currentRouteUuid)(resources);
+    const frozenByUser = currentResource && getResource((currentResource as ProjectResource).frozenByUuid as string)(resources);
+    const frozenByFullName = frozenByUser && (frozenByUser as Resource & { fullName: string }).fullName;
+    const isSelected = selectedResourceUuid === properties.currentRouteUuid;
+
+    return {
+        isAdmin: auth.user?.isAdmin,
+        currentResource,
+        frozenByFullName,
+        isSelected,
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleCardClick: (uuid: string) => {
+        dispatch<any>(loadDetailsPanel(uuid));
+        dispatch<any>(setSelectedResourceUuid(uuid));
+        dispatch<any>(deselectAllOthers(uuid));
+    },
+    
+});
+
+type DetailsCardProps = WithStyles<CssRules> & {
+    currentResource: ProjectResource | UserResource;
+    frozenByFullName?: string;
+    isAdmin: boolean;
+    isSelected: boolean;
+    handleCardClick: (resource: any) => void;
+};
+
+type UserCardProps = WithStyles<CssRules> & {
+    currentResource: UserResource;
+    isAdmin: boolean;
+    isSelected: boolean;
+    handleCardClick: (resource: any) => void;
+};
+
+type ProjectCardProps = WithStyles<CssRules> & {
+    currentResource: ProjectResource;
+    frozenByFullName: string | undefined;
+    isAdmin: boolean;
+    isSelected: boolean;
+    handleCardClick: (resource: any) => void;
+};
+
+export const ProjectDetailsCard = connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(
+    withStyles(styles)((props: DetailsCardProps) => {
+        const { classes, currentResource, frozenByFullName, handleCardClick, isAdmin, isSelected } = props;
+        if (!currentResource) {
+            return null;
+        }
+        switch (currentResource.kind as string) {
+            case ResourceKind.USER:
+                return (
+                    <UserCard
+                        classes={classes}
+                        currentResource={currentResource as UserResource}
+                        isAdmin={isAdmin}
+                        isSelected={isSelected}
+                        handleCardClick={handleCardClick}
+                    />
+                );
+            case ResourceKind.PROJECT:
+                return (
+                    <ProjectCard
+                        classes={classes}
+                        currentResource={currentResource as ProjectResource}
+                        frozenByFullName={frozenByFullName}
+                        isAdmin={isAdmin}
+                        isSelected={isSelected}
+                        handleCardClick={handleCardClick}
+                    />
+                );
+            default:
+                return null;
+        }
+    })
+);
+
+const UserCard: React.FC<UserCardProps> = ({ classes, currentResource, handleCardClick, isSelected }) => {
+    const { fullName, uuid } = currentResource as UserResource & { fullName: string };
+
+    return (
+        <Card
+            className={classes.root}
+            onClick={() => handleCardClick(uuid)}
+            data-cy='user-details-card'
+        >
+            <Grid
+                container
+                wrap='nowrap'
+                className={classes.cardHeaderContainer}
+            >
+                <CardHeader
+                    className={classes.cardHeader}
+                    title={
+                        <section className={classes.userNameContainer}>
+                            <Typography
+                                noWrap
+                                variant='h6'
+                            >
+                                {fullName}
+                            </Typography>
+                            <section className={classes.accountStatusSection}>
+                                {!currentResource.isActive && (
+                                    <Typography>
+                                        <UserResourceAccountStatus uuid={uuid} />
+                                    </Typography>
+                            )}
+                            </section>
+                        </section>
+                    }
+                />
+                {isSelected && <MultiselectToolbar />}
+            </Grid>
+        </Card>
+    );
+};
+
+const ProjectCard: React.FC<ProjectCardProps> = ({ classes, currentResource, frozenByFullName, handleCardClick, isSelected }) => {
+    const { name, description, uuid } = currentResource as ProjectResource;
+    const [showDescription, setShowDescription] = React.useState(false);
+    const [showProperties, setShowProperties] = React.useState(false);
+
+    const toggleDescription = () => {
+        setShowDescription(!showDescription);
+    };
+
+    const toggleProperties = () => {
+        setShowProperties(!showProperties);
+    };
+
+    return (
+        <Card
+            className={classes.root}
+            onClick={() => handleCardClick(uuid)}
+            data-cy='project-details-card'
+        >
+            <Grid
+                container
+                wrap='nowrap'
+                className={classes.cardHeaderContainer}
+            >
+                <CardHeader
+                    className={classes.cardHeader}
+                    title={
+                        <section className={classes.nameSection}>
+                            <section className={classes.namePlate}>
+                                <Typography
+                                    variant='h6'
+                                    style={{ marginRight: '1rem' }}
+                                >
+                                    {name}
+                                </Typography>
+                                <FavoriteStar
+                                    className={classes.faveIcon}
+                                    resourceUuid={currentResource.uuid}
+                                />
+                                <PublicFavoriteStar
+                                    className={classes.faveIcon}
+                                    resourceUuid={currentResource.uuid}
+                                />
+                                {!!frozenByFullName && (
+                                    <Tooltip
+                                        className={classes.frozenIcon}
+                                        disableFocusListener
+                                        title={<span>Project was frozen by {frozenByFullName}</span>}
+                                    >
+                                        <FreezeIcon style={{ fontSize: 'inherit' }} />
+                                    </Tooltip>
+                                )}
+                            </section>
+                        </section>
+                    }
+                />
+                {isSelected && <MultiselectToolbar injectedStyles={classes.projectToolbar} />}
+            </Grid>
+            <section onClick={(ev) => ev.stopPropagation()}>
+                {description ? (
+                    <section
+                        onClick={toggleDescription}
+                        className={classes.descriptionToggle}
+                    >
+                        <ExpandChevronRight expanded={showDescription} />
+                        <section className={classes.showMore}>
+                            <Collapse
+                                in={showDescription}
+                                timeout='auto'
+                                collapsedHeight='1.25rem'
+                            >
+                                <Typography
+                                    className={classes.description}
+                                    data-cy='project-description'
+                                    //dangerouslySetInnerHTML is ok here only if description is sanitized,
+                                    //which it is before it is loaded into the redux store
+                                    dangerouslySetInnerHTML={{ __html: description }}
+                                />
+                            </Collapse>
+                        </section>
+                    </section>
+                ) : (
+                    <Typography
+                        className={classes.noDescription}
+                        data-cy='no-description'
+                    >
+                        no description available
+                    </Typography>
+                )}
+                {typeof currentResource.properties === 'object' && Object.keys(currentResource.properties).length > 0 ? (
+                    <section
+                        onClick={toggleProperties}
+                        className={classes.descriptionToggle}
+                    >
+                        <ExpandChevronRight expanded={showProperties} />
+                        <section className={classes.showMore}>
+                            <Collapse
+                                in={showProperties}
+                                timeout='auto'
+                                collapsedHeight='35px'
+                            >
+                                <div
+                                    className={classes.description}
+                                    data-cy='project-description'
+                                >
+                                    <CardContent className={classes.cardContent}>
+                                        <Typography component='div' className={classes.chipSection}>
+                                            {Object.keys(currentResource.properties).map((k) =>
+                                                Array.isArray(currentResource.properties[k])
+                                                    ? currentResource.properties[k].map((v: string) => getPropertyChip(k, v, undefined, classes.tag))
+                                                    : getPropertyChip(k, currentResource.properties[k], undefined, classes.tag)
+                                            )}
+                                        </Typography>
+                                    </CardContent>
+                                </div>
+                            </Collapse>
+                        </section>
+                    </section>
+                ) : null}
+            </section>
+        </Card>
+    );
+};
index 24b5c0a96dcd6eec494301ab2eb47381047104b9..cfdae3fe8347269accc3eac3b39c5f7a74bac6ea 100644 (file)
@@ -43,10 +43,12 @@ export const PropertyChipComponent = connect(mapStateToProps, mapDispatchToProps
     ({ propKey, propValue, vocabulary, className, onCopy, onDelete }: PropertyChipComponentProps) => {
         const label = `${getTagKeyLabel(propKey, vocabulary)}: ${getTagValueLabel(propKey, propValue, vocabulary)}`;
         return (
-            <CopyToClipboard key={propKey} text={label} onCopy={() => onCopy("Copied to clipboard")}>
-                <Chip onDelete={onDelete} key={propKey}
-                    className={className} label={label} />
-            </CopyToClipboard>
+            <span onClick={(ev)=>ev.stopPropagation()}>
+                <CopyToClipboard key={propKey} text={label} onCopy={() => onCopy("Copied to clipboard")}>
+                    <Chip onDelete={onDelete} key={propKey}
+                        className={className} label={label} />
+                </CopyToClipboard>
+            </span>
         );
     }
 );
index 0147312912730849da6fceba14be2fa799f53ec6..df23aeeb89aa37e2f06dad24232637e2c0a947c2 100644 (file)
@@ -42,10 +42,12 @@ export const ResourcePropertiesForm = connect(mapStateToProps)(({ handleSubmit,
     const propertyValue = applySelector(formValueSelector(props.form));
     return <form data-cy='resource-properties-form' onSubmit={handleSubmit}>
         <Grid container spacing={16} classes={classes}>
-            <Grid item xs>
+            <Grid item xs 
+            data-cy='key-input'>
                 <PropertyKeyField clearPropertyKeyOnSelect />
             </Grid>
-            <Grid item xs>
+            <Grid item xs
+            data-cy='value-input'>
                 <PropertyValueField />
             </Grid>
             <Grid item>
index f338687de9aadec9bb277268262379b785c0a797..cc1b57da485ce08b0e10cd8f4d237517a62f82a2 100644 (file)
@@ -16,6 +16,7 @@ import { noop } from 'lodash';
 import { ResourceKind } from "models/resource";
 import { IllegalNamingWarning } from "components/warning/warning";
 import { GroupClass } from "models/group";
+import { setSelectedResourceUuid } from "store/selected-resource/selected-resource-actions";
 
 export interface SidePanelTreeProps {
     onItemActivation: (id: string) => void;
@@ -32,6 +33,8 @@ const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): Side
     },
     toggleItemActive: (_, { id }) => {
         dispatch<any>(activateSidePanelTreeItem(id));
+        const isSidePanelCat = Object.values(SidePanelTreeCategory).includes(id as SidePanelTreeCategory);
+        dispatch<any>(setSelectedResourceUuid(isSidePanelCat ? null : id));
         props.onItemActivation(id);
     },
     toggleItemOpen: (_, { id }) => {
index cdfd0c300f5ca7444c8dea43503abf603be08499..9654f4eb27402b7bf96544d20b0d224cc703cd6e 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, { useEffect } from 'react';
 import { StyleRulesCallback, WithStyles, withStyles, Grid, LinearProgress } from '@material-ui/core';
 import { User } from "models/user";
 import { ArvadosTheme } from 'common/custom-theme';
@@ -11,6 +11,7 @@ import { LoginPanel } from 'views/login-panel/login-panel';
 import { InactivePanel } from 'views/inactive-panel/inactive-panel';
 import { WorkbenchLoadingScreen } from 'views/workbench/workbench-loading-screen';
 import { MainAppBar } from 'views-components/main-app-bar/main-app-bar';
+import { Routes } from 'routes/routes';
 
 type CssRules = 'root';
 
@@ -35,10 +36,12 @@ export interface MainPanelRootDataProps {
     sidePanelIsCollapsed: boolean;
     isTransitioning: boolean;
     currentSideWidth: number;
+    currentRoute: string;
 }
 
 interface MainPanelRootDispatchProps {
-    toggleSidePanel: () => void
+    toggleSidePanel: () => void,
+    setCurrentRouteUuid: (uuid: string | null) => void;
 }
 
 type MainPanelRootProps = MainPanelRootDataProps & MainPanelRootDispatchProps & WithStyles<CssRules>;
@@ -46,7 +49,19 @@ type MainPanelRootProps = MainPanelRootDataProps & MainPanelRootDispatchProps &
 export const MainPanelRoot = withStyles(styles)(
     ({ classes, loading, working, user, buildInfo, uuidPrefix,
         isNotLinking, isLinkingPath, siteBanner, sessionIdleTimeout, 
-        sidePanelIsCollapsed, isTransitioning, currentSideWidth}: MainPanelRootProps) =>{
+        sidePanelIsCollapsed, isTransitioning, currentSideWidth, currentRoute, setCurrentRouteUuid}: MainPanelRootProps) =>{
+
+            useEffect(() => {
+                const splitRoute = currentRoute.split('/');
+                const uuid = splitRoute[splitRoute.length - 1];
+                if(Object.values(Routes).includes(`/${uuid}`) === false) {
+                    setCurrentRouteUuid(uuid);
+                } else {
+                    setCurrentRouteUuid(null);
+                }
+                // eslint-disable-next-line react-hooks/exhaustive-deps
+            }, [currentRoute]);
+
         return loading
             ? <WorkbenchLoadingScreen />
             : <>
index 264390a8b3eec8478b9d00a796a10842ad1b04a6..556ce0d75c54aba7dc4324edcd0220389ac40434 100644 (file)
@@ -11,6 +11,7 @@ import { isWorkbenchLoading } from 'store/workbench/workbench-actions';
 import { LinkAccountPanelStatus } from 'store/link-account-panel/link-account-panel-reducer';
 import { matchLinkAccountRoute } from 'routes/routes';
 import { toggleSidePanel } from "store/side-panel/side-panel-action";
+import { propertiesActions } from 'store/properties/properties-actions';
 
 const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
     return {
@@ -25,7 +26,8 @@ const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
         sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
         sidePanelIsCollapsed: state.sidePanel.collapsedState,
         isTransitioning: state.detailsPanel.isTransitioning,
-        currentSideWidth: state.sidePanel.currentSideWidth
+        currentSideWidth: state.sidePanel.currentSideWidth,
+        currentRoute: state.router.location ? state.router.location.pathname : '',
     };
 };
 
@@ -33,7 +35,9 @@ const mapDispatchToProps = (dispatch) => {
     return {
         toggleSidePanel: (collapsedState)=>{
             return dispatch(toggleSidePanel(collapsedState))
-        }
+        },
+        setCurrentRouteUuid: (uuid: string) => {
+            return dispatch(propertiesActions.SET_PROPERTY({key: 'currentRouteUuid', value: uuid}))}
     }
 };
 
index 2ddfca8178577e8402041bfbb3807968ea7a91af..0425335e4212b5b9942ed8f337ea60b2c7ad5f83 100644 (file)
@@ -52,12 +52,15 @@ import { CollectionResource } from 'models/collection';
 import { resourceIsFrozen } from 'common/frozen-resources';
 import { ProjectResource } from 'models/project';
 import { deselectAllOthers, toggleOne } from 'store/multiselect/multiselect-actions';
+import { ProjectDetailsCard } from 'views-components/project-details-card/project-details-card';
 
 type CssRules = 'root' | 'button' ;
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         width: '100%',
+        display: 'flex',
+        flexDirection: 'column',
     },
     button: {
         marginLeft: theme.spacing.unit,
@@ -266,6 +269,7 @@ export const ProjectPanel = withStyles(styles)(
             render() {
                 const { classes } = this.props;
                 return <div data-cy='project-panel' className={classes.root}>
+                    <ProjectDetailsCard />
                     <DataExplorer
                         id={PROJECT_PANEL_ID}
                         onRowClick={this.handleRowClick}
@@ -274,6 +278,7 @@ export const ProjectPanel = withStyles(styles)(
                         contextMenuColumn={true}
                         defaultViewIcon={ProjectIcon}
                         defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+                        selectedResourceUuid={this.props.currentItemId}
                     />
                 </div>
             }