--- /dev/null
+// 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`));
+ })
+ );
+ });
+});
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,
},
},
root: {
height: "100%",
+ flex: 1,
+ overflowY: "auto",
},
moreOptionsButton: {
padding: 0,
title?: React.ReactNode;
progressBar?: React.ReactNode;
paperKey?: string;
- currentItemUuid: string;
+ currentRouteUuid: string;
+ selectedResourceUuid: string;
elementPath?: string;
isMSToolbarVisible: boolean;
checkedList: TCheckedList;
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;
hideSearchInput,
paperKey,
fetchMode,
- currentItemUuid,
+ selectedResourceUuid,
currentRoute,
title,
progressBar,
</Grid>
)}
{!!progressBar && progressBar}
- {this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+ {this.multiSelectToolbarInTitle && <MultiselectToolbar injectedStyles={classes.msToolbarStyles} />}
{(!hideColumnSelector || !hideSearchInput || !!actions) && (
<Grid
className={classes.headerMenu}
</Grid>
)}
</div>
- {!this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+ {!this.multiSelectToolbarInTitle && <MultiselectToolbar isSubPanel={true} injectedStyles={classes.subpanelToolbarStyles}/>}
<Grid
item
xs="auto"
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}
/>
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>>;
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;
}
}
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);
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 });
}
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];
);
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'}
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}
--- /dev/null
+// 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
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";
display: "flex",
flexDirection: "row",
width: 0,
- height: '2.7rem',
+ height: '2.5rem',
padding: 0,
margin: "1rem auto auto 0.3rem",
overflow: 'hidden',
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 = {
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";
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(
menuDirection.HORIZONTAL
);
+ const targetResources = selectedResourceUuid ? {[selectedResourceUuid]: true} as TCheckedList : checkedList
+
return (
<React.Fragment>
<Toolbar
<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
>
<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>
<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({})}
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) {
? 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:
//--------------------------------------------------//
-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,
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:
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,
};
} 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';
RUNNING_PROCESS_ADMIN,
PROCESS_ADMIN,
PROJECT,
+ ROOT_PROJECT,
PROJECT_ADMIN,
+ ROOT_PROJECT_ADMIN,
FROZEN_PROJECT,
FROZEN_PROJECT_ADMIN,
READONLY_PROJECT,
[WORKFLOW]: [msWorkflowActionSet, msWorkflowActionFilter],
[READONLY_WORKFLOW]: [msWorkflowActionSet, msReadOnlyWorkflowActionFilter],
+
+ [ROOT_PROJECT]: [UserDetailsActionSet, allActionNames(UserDetailsActionSet)],
+ [ROOT_PROJECT_ADMIN]: [UserDetailsActionSet, allActionNames(UserDetailsActionSet)],
};
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) => {
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";
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);
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};
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<{}>(),
);
};
-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())
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: []
};
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) {
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:
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));
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, {
--- /dev/null
+// 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
+});
--- /dev/null
+// 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
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 {
properties: propertiesReducer,
resources: resourcesReducer,
router: routerReducer,
+ selectedResourceUuid: selectedResourceReducer,
snackbar: snackbarReducer,
treePicker: treePickerReducer,
treePickerSearch: treePickerSearchReducer,
}
};
-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());
ACTIVE = 'Active',
INACTIVE = 'Inactive',
SETUP = 'Setup',
+ OTHER = 'Other',
}
export const getUserAccountStatus = (state: RootState, uuid: string) => {
--- /dev/null
+// 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],
+ },
+ ],
+];
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;
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,
dispatch<any>(setCheckedListOnStore(checkedList));
},
+ setSelectedUuid: (uuid: string | null) => {
+ dispatch<any>(setSelectedResourceUuid(uuid));
+ },
+
onRowClick,
onRowDoubleClick,
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";
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) => {
);
};
-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) {
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";
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';
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:
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);
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));
});
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>;
}
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(
) || authConfig.clusterConfig.Collections.TrustAllContent;
}
- const item = getItem(res);
+ const item = getItem(res, pathname);
return <Grid
data-cy='details-panel'
container
</Tooltip>
</Grid>
<Grid item>
- <IconButton color="inherit" onClick={onCloseDrawer}>
+ <IconButton color="inherit" onClick={()=>onCloseDrawer(CLOSE_DRAWER)}>
<CloseIcon />
</IconButton>
</Grid>
--- /dev/null
+// 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>
+ ));
onRefreshPage: () => void;
onDetailsPanelToggle: () => void;
buttonVisible: boolean;
+ projectUuid: string;
}
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));
}
}} />
</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>}
icon: DetailsIcon,
hasAlts: false,
isForMulti: false,
- execute: (dispatch) => {
- dispatch<any>(toggleDetailsPanel());
+ execute: (dispatch, resources) => {
+ dispatch<any>(toggleDetailsPanel(resources[0].uuid));
},
};
--- /dev/null
+// 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));
+ },
+ },
+ ],
+];
--- /dev/null
+// 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>
+ );
+};
({ 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>
);
}
);
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>
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;
},
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 }) => {
//
// 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';
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';
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>;
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 />
: <>
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 {
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 : '',
};
};
return {
toggleSidePanel: (collapsedState)=>{
return dispatch(toggleSidePanel(collapsedState))
- }
+ },
+ setCurrentRouteUuid: (uuid: string) => {
+ return dispatch(propertiesActions.SET_PROPERTY({key: 'currentRouteUuid', value: uuid}))}
}
};
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,
render() {
const { classes } = this.props;
return <div data-cy='project-panel' className={classes.root}>
+ <ProjectDetailsCard />
<DataExplorer
id={PROJECT_PANEL_ID}
onRowClick={this.handleRowClick}
contextMenuColumn={true}
defaultViewIcon={ProjectIcon}
defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+ selectedResourceUuid={this.props.currentItemId}
/>
</div>
}