1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import * as React from 'react';
6 import { List, ListItem, ListItemIcon, Checkbox, Radio } from "@material-ui/core";
7 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
8 import { ReactElement } from "react";
9 import CircularProgress from '@material-ui/core/CircularProgress';
10 import classnames from "classnames";
12 import { ArvadosTheme } from '~/common/custom-theme';
13 import { SidePanelRightArrowIcon } from '../icon/icon';
15 type CssRules = 'list'
19 | 'toggableIconContainer'
27 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
36 transform: 'translate(0px)',
39 toggableIconContainer: {
40 color: theme.palette.grey["700"],
51 color: theme.palette.primary.main,
54 transition: 'all 0.1s ease',
57 transition: 'all 0.1s ease',
58 transform: 'rotate(90deg)',
61 width: theme.spacing.unit * 3,
62 height: theme.spacing.unit * 3,
63 margin: `0 ${theme.spacing.unit}px`,
65 color: theme.palette.grey["500"],
73 backgroundColor: 'rgba(0, 0, 0, 0.08)',
78 export enum TreeItemStatus {
84 export interface TreeItem<T> {
90 status: TreeItemStatus;
91 items?: Array<TreeItem<T>>;
94 export interface TreeProps<T> {
95 disableRipple?: boolean;
96 currentItemUuid?: string;
97 items?: Array<TreeItem<T>>;
99 onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
100 render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
101 showSelection?: boolean | ((item: TreeItem<T>) => boolean);
102 levelIndentation?: number;
103 itemRightPadding?: number;
104 toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
105 toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
106 toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
109 * When set to true use radio buttons instead of checkboxes for item selection.
110 * This does not guarantee radio group behavior (i.e item mutual exclusivity).
111 * Any item selection logic must be done in the toggleItemActive callback prop.
113 useRadioButtons?: boolean;
116 const flatTree = (depth: number, items?: any): [] => {
117 return items ? items.reduce((prev: any, next: any) => {
118 const { items } = next;
119 // delete next.items;
123 ...(next.open ? flatTree(depth + 1, items) : []),
128 const getActionAndId = (event: any, initAction: string | undefined = undefined) => {
129 const { nativeEvent: { target } } = event;
130 let currentTarget: HTMLElement = target as HTMLElement;
131 let action: string | undefined = initAction || currentTarget.dataset.action;
132 let id: string | undefined = currentTarget.dataset.id;
134 while (action === undefined || id === undefined) {
135 currentTarget = currentTarget.parentElement as HTMLElement;
137 if (!currentTarget) {
141 action = action || currentTarget.dataset.action;
142 id = id || currentTarget.dataset.id;
148 export const Tree = withStyles(styles)(
149 class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
150 render(): ReactElement<any> {
151 const level = this.props.level ? this.props.level : 0;
152 const { classes, render, items, toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = this.props;
153 const { list, listItem, loader, toggableIconContainer, renderContainer, childItem, active } = classes;
154 const showSelection = typeof this.props.showSelection === 'function'
155 ? this.props.showSelection
156 : () => this.props.showSelection ? true : false;
158 const { levelIndentation = 20, itemRightPadding = 20 } = this.props;
160 const flatItems = (items || [])
161 .map(parentItem => ({
163 items: flatTree(2, parentItem.items || []),
166 return <List className={list}>
167 {flatItems && flatItems.map((it: TreeItem<T>, idx: number) =>
168 <div key={`item/${level}/${it.id}`}>
169 <ListItem button className={listItem}
171 paddingLeft: (level + 1) * levelIndentation,
172 paddingRight: itemRightPadding,
174 disableRipple={disableRipple}
175 onClick={event => toggleItemActive(event, it)}
176 selected={showSelection(it) && it.id === currentItemUuid}
177 onContextMenu={(event) => this.props.onContextMenu(event, it)}>
178 {it.status === TreeItemStatus.PENDING ?
179 <CircularProgress size={10} className={loader} /> : null}
180 <i onClick={(e) => this.handleToggleItemOpen(it, e)}
181 className={toggableIconContainer}>
182 <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
183 {this.getProperArrowAnimation(it.status, it.items!)}
186 {showSelection(it) && !useRadioButtons &&
188 checked={it.selected}
189 className={classes.checkbox}
191 onClick={this.handleCheckboxChange(it)} />}
192 {showSelection(it) && useRadioButtons &&
194 checked={it.selected}
195 className={classes.checkbox}
197 <div className={renderContainer}>
201 {it.items && it.items.length > 0 &&
203 onContextMenu={(event) => {
204 const [action, id] = getActionAndId(event, 'CONTEXT_MENU');
205 this.props.onContextMenu(event, { id } as any);
207 onClick={(event) => {
208 const [action, id] = getActionAndId(event);
213 this.handleToggleItemOpen({ id } as any, event);
215 case 'TOGGLE_ACTIVE':
216 toggleItemActive(event, { id } as any);
226 .map((item: any) => <div key={item.id} data-id={item.id}
227 className={classnames(childItem, { [active]: item.active })}
228 style={{ paddingLeft: `${item.depth * levelIndentation}px`}}>
229 <i data-action="TOGGLE_OPEN" className={toggableIconContainer}>
230 <ListItemIcon className={this.getToggableIconClassNames(item.open, item.active)}>
231 {this.getProperArrowAnimation(item.status, item.items!)}
234 <div style={{ marginLeft: '8px' }} data-action="TOGGLE_ACTIVE" className={renderContainer}>
240 showSelection={this.props.showSelection}
243 disableRipple={disableRipple}
244 toggleItemOpen={toggleItemOpen}
245 toggleItemActive={toggleItemActive}
247 onContextMenu={onContextMenu}
248 toggleItemSelection={this.props.toggleItemSelection} /> */}
254 getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
255 return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
258 isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
259 return status === TreeItemStatus.PENDING ||
260 (status === TreeItemStatus.LOADED && !items) ||
261 (status === TreeItemStatus.LOADED && items && items.length === 0);
264 getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
265 const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
266 return classnames(toggableIcon, {
268 [iconClose]: !isOpen,
273 handleCheckboxChange = (item: TreeItem<T>) => {
274 const { toggleItemSelection } = this.props;
275 return toggleItemSelection
276 ? (event: React.MouseEvent<HTMLElement>) => {
277 event.stopPropagation();
278 toggleItemSelection(event, item);
283 handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
284 event.stopPropagation();
285 this.props.toggleItemOpen(event, item);