X-Git-Url: https://git.arvados.org/arvados-workbench2.git/blobdiff_plain/c92ee19217ebd5cce3c17f757b45bcfa1d5bc702..c69c6d43021b29186717ad30bea95cf50ee8c1e6:/src/components/tree/tree.tsx diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index 93e90c77..fc9dbc74 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -2,46 +2,377 @@ // // SPDX-License-Identifier: AGPL-3.0 -import * as React from 'react'; -import List from "@material-ui/core/List/List"; -import ListItem from "@material-ui/core/ListItem/ListItem"; +import React from 'react'; +import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core"; +import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles'; +import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon } from 'components/icon/icon'; import { ReactElement } from "react"; -import Collapse from "@material-ui/core/Collapse/Collapse"; +import CircularProgress from '@material-ui/core/CircularProgress'; +import classnames from "classnames"; + +import { ArvadosTheme } from 'common/custom-theme'; +import { SidePanelRightArrowIcon } from '../icon/icon'; +import { ResourceKind } from 'models/resource'; +import { GroupClass } from 'models/group'; + +type CssRules = 'list' + | 'listItem' + | 'active' + | 'loader' + | 'toggableIconContainer' + | 'iconClose' + | 'renderContainer' + | 'iconOpen' + | 'toggableIcon' + | 'checkbox' + | 'childItem' + | 'childItemIcon'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + list: { + padding: '3px 0px' + }, + listItem: { + padding: '3px 0px', + }, + loader: { + position: 'absolute', + transform: 'translate(0px)', + top: '3px' + }, + toggableIconContainer: { + color: theme.palette.grey["700"], + height: '14px', + width: '14px', + }, + toggableIcon: { + fontSize: '14px' + }, + renderContainer: { + flex: 1 + }, + iconClose: { + transition: 'all 0.1s ease', + }, + iconOpen: { + transition: 'all 0.1s ease', + transform: 'rotate(90deg)', + }, + checkbox: { + width: theme.spacing.unit * 3, + height: theme.spacing.unit * 3, + margin: `0 ${theme.spacing.unit}px`, + padding: 0, + color: theme.palette.grey["500"], + }, + childItem: { + cursor: 'pointer', + display: 'flex', + padding: '3px 20px', + fontSize: '0.875rem', + alignItems: 'center', + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.08)', + } + }, + childItemIcon: { + marginLeft: '8px', + marginRight: '16px', + color: 'rgba(0, 0, 0, 0.54)', + }, + active: { + color: theme.palette.primary.main, + }, +}); + +export enum TreeItemStatus { + INITIAL = 'initial', + PENDING = 'pending', + LOADED = 'loaded' +} export interface TreeItem { data: T; id: string; open: boolean; + active: boolean; + selected?: boolean; + initialState?: boolean; + indeterminate?: boolean; + flatTree?: boolean; + status: TreeItemStatus; items?: Array>; } -interface TreeProps { +export interface TreeProps { + disableRipple?: boolean; + currentItemUuid?: string; items?: Array>; - render: (item: T) => ReactElement<{}>; - toggleItem: (id: string) => any; level?: number; + itemsMap?: Map>; + onContextMenu: (event: React.MouseEvent, item: TreeItem) => void; + render: (item: TreeItem, level?: number) => ReactElement<{}>; + showSelection?: boolean | ((item: TreeItem) => boolean); + levelIndentation?: number; + itemRightPadding?: number; + toggleItemActive: (event: React.MouseEvent, item: TreeItem) => void; + toggleItemOpen: (event: React.MouseEvent, item: TreeItem) => void; + toggleItemSelection?: (event: React.MouseEvent, item: TreeItem) => void; + + /** + * When set to true use radio buttons instead of checkboxes for item selection. + * This does not guarantee radio group behavior (i.e item mutual exclusivity). + * Any item selection logic must be done in the toggleItemActive callback prop. + */ + useRadioButtons?: boolean; } -class Tree extends React.Component, {}> { - render(): ReactElement { - const level = this.props.level ? this.props.level : 0; - return - {this.props.items && this.props.items.map((it: TreeItem, idx: number) => -
- this.props.toggleItem(it.id)} style={{paddingLeft: (level + 1) * 20}}> - - {this.props.render(it.data)} - - {it.items && it.items.length > 0 && - - - } -
)} -
+const getActionAndId = (event: any, initAction: string | undefined = undefined) => { + const { nativeEvent: { target } } = event; + let currentTarget: HTMLElement = target as HTMLElement; + let action: string | undefined = initAction || currentTarget.dataset.action; + let id: string | undefined = currentTarget.dataset.id; + + while (action === undefined || id === undefined) { + currentTarget = currentTarget.parentElement as HTMLElement; + + if (!currentTarget) { + break; + } + + action = action || currentTarget.dataset.action; + id = id || currentTarget.dataset.id; } + + return [action, id]; +}; + +interface FlatTreeProps { + it: TreeItem; + levelIndentation: number; + onContextMenu: Function; + handleToggleItemOpen: Function; + toggleItemActive: Function; + getToggableIconClassNames: Function; + getProperArrowAnimation: Function; + itemsMap?: Map>; + classes: any; + showSelection: any; + useRadioButtons?: boolean; + handleCheckboxChange: Function; } -export default Tree; +const FLAT_TREE_ACTIONS = { + toggleOpen: 'TOGGLE_OPEN', + contextMenu: 'CONTEXT_MENU', + toggleActive: 'TOGGLE_ACTIVE', +}; + +const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) => { + let Icon = ProjectIcon; + + if (groupClass === GroupClass.FILTER) { + Icon = FilterGroupIcon; + } + + if (type) { + switch (type) { + case 'directory': + Icon = DirectoryIcon; + break; + case 'file': + Icon = FileIcon; + break; + default: + Icon = DefaultIcon; + } + } + + if (kind) { + switch (kind) { + case ResourceKind.COLLECTION: + Icon = CollectionIcon; + break; + default: + break; + } + } + + return ; +}); + +const FlatTree = (props: FlatTreeProps) => +
{ + const id = getActionAndId(event, FLAT_TREE_ACTIONS.contextMenu)[1]; + props.onContextMenu(event, { id } as any); + }} + onClick={(event) => { + const [action, id] = getActionAndId(event); + + if (action && id) { + const item = props.itemsMap ? props.itemsMap[id] : { id }; + + switch (action) { + case FLAT_TREE_ACTIONS.toggleOpen: + props.handleToggleItemOpen(item as any, event); + break; + case FLAT_TREE_ACTIONS.toggleActive: + props.toggleItemActive(event, item as any); + break; + default: + break; + } + } + }} + > + { + (props.it.items || []) + .map((item: any) =>
+ + + {props.getProperArrowAnimation(item.status, item.items!)} + + + {props.showSelection(item) && !props.useRadioButtons && + } + {props.showSelection(item) && props.useRadioButtons && + } +
+ + + + {item.data.name} + + +
+
) + } +
; + +export const Tree = withStyles(styles)( + class Component extends React.Component & WithStyles, {}> { + render(): ReactElement { + const level = this.props.level ? this.props.level : 0; + const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = this.props; + const { list, listItem, loader, toggableIconContainer, renderContainer } = classes; + const showSelection = typeof this.props.showSelection === 'function' + ? this.props.showSelection + : () => this.props.showSelection ? true : false; + + const { levelIndentation = 20, itemRightPadding = 20 } = this.props; + + return + {items && items.map((it: TreeItem, idx: number) => +
+ toggleItemActive(event, it)} + selected={showSelection(it) && it.id === currentItemUuid} + onContextMenu={(event) => this.props.onContextMenu(event, it)}> + {it.status === TreeItemStatus.PENDING ? + : null} + this.handleToggleItemOpen(it, e)} + className={toggableIconContainer}> + + {this.getProperArrowAnimation(it.status, it.items!)} + + + {showSelection(it) && !useRadioButtons && + } + {showSelection(it) && useRadioButtons && + } +
+ {render(it, level)} +
+
+ { + it.open && it.items && it.items.length > 0 && + it.flatTree ? + : + + + + } +
)} +
; + } + + getProperArrowAnimation = (status: string, items: Array>) => { + return this.isSidePanelIconNotNeeded(status, items) ? : ; + } + + isSidePanelIconNotNeeded = (status: string, items: Array>) => { + return status === TreeItemStatus.PENDING || + (status === TreeItemStatus.LOADED && !items) || + (status === TreeItemStatus.LOADED && items && items.length === 0); + } + + getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => { + const { iconOpen, iconClose, active, toggableIcon } = this.props.classes; + return classnames(toggableIcon, { + [iconOpen]: isOpen, + [iconClose]: !isOpen, + [active]: isActive + }); + } + + handleCheckboxChange = (item: TreeItem) => { + const { toggleItemSelection } = this.props; + return toggleItemSelection + ? (event: React.MouseEvent) => { + event.stopPropagation(); + toggleItemSelection(event, item); + } + : undefined; + } + + handleToggleItemOpen = (item: TreeItem, event: React.MouseEvent) => { + event.stopPropagation(); + this.props.toggleItemOpen(event, item); + } + } +);