17114: Code cleanup, style cleanup
[arvados-workbench2.git] / src / components / tree / tree.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
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 { ProjectIcon } from '~/components/icon/icon';
9 import { ReactElement } from "react";
10 import CircularProgress from '@material-ui/core/CircularProgress';
11 import classnames from "classnames";
12
13 import { ArvadosTheme } from '~/common/custom-theme';
14 import { SidePanelRightArrowIcon } from '../icon/icon';
15
16 type CssRules = 'list'
17     | 'listItem'
18     | 'active'
19     | 'loader'
20     | 'toggableIconContainer'
21     | 'iconClose'
22     | 'renderContainer'
23     | 'iconOpen'
24     | 'toggableIcon'
25     | 'checkbox'
26     | 'childItem'
27     | 'childItemIcon';
28
29 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
30     list: {
31         padding: '3px 0px'
32     },
33     listItem: {
34         padding: '3px 0px',
35     },
36     loader: {
37         position: 'absolute',
38         transform: 'translate(0px)',
39         top: '3px'
40     },
41     toggableIconContainer: {
42         color: theme.palette.grey["700"],
43         height: '14px',
44         width: '14px',
45     },
46     toggableIcon: {
47         fontSize: '14px'
48     },
49     renderContainer: {
50         flex: 1
51     },
52     iconClose: {
53         transition: 'all 0.1s ease',
54     },
55     iconOpen: {
56         transition: 'all 0.1s ease',
57         transform: 'rotate(90deg)',
58     },
59     checkbox: {
60         width: theme.spacing.unit * 3,
61         height: theme.spacing.unit * 3,
62         margin: `0 ${theme.spacing.unit}px`,
63         padding: 0,
64         color: theme.palette.grey["500"],
65     },
66     childItem: {
67         cursor: 'pointer',
68         display: 'flex',
69         padding: '3px 20px',
70         fontSize: '0.875rem',
71         alignItems: 'center',
72         '&:hover': {
73             backgroundColor: 'rgba(0, 0, 0, 0.08)',
74         }
75     },
76     childItemIcon: {
77         marginLeft: '8px',
78         marginRight: '16px',
79         color: 'rgba(0, 0, 0, 0.54)',
80     },
81     active: {
82         color: theme.palette.primary.main,
83     },
84 });
85
86 export enum TreeItemStatus {
87     INITIAL = 'initial',
88     PENDING = 'pending',
89     LOADED = 'loaded'
90 }
91
92 export interface TreeItem<T> {
93     data: T;
94     id: string;
95     open: boolean;
96     active: boolean;
97     selected?: boolean;
98     status: TreeItemStatus;
99     items?: Array<TreeItem<T>>;
100 }
101
102 export interface TreeProps<T> {
103     disableRipple?: boolean;
104     currentItemUuid?: string;
105     items?: Array<TreeItem<T>>;
106     level?: number;
107     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
108     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
109     showSelection?: boolean | ((item: TreeItem<T>) => boolean);
110     levelIndentation?: number;
111     itemRightPadding?: number;
112     toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
113     toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
114     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
115
116     /**
117      * When set to true use radio buttons instead of checkboxes for item selection.
118      * This does not guarantee radio group behavior (i.e item mutual exclusivity).
119      * Any item selection logic must be done in the toggleItemActive callback prop.
120      */
121     useRadioButtons?: boolean;
122 }
123
124 const getActionAndId = (event: any, initAction: string | undefined = undefined) => {
125     const { nativeEvent: { target } } = event;
126     let currentTarget: HTMLElement = target as HTMLElement;
127     let action: string | undefined = initAction || currentTarget.dataset.action;
128     let id: string | undefined = currentTarget.dataset.id;
129
130     while (action === undefined || id === undefined) {
131         currentTarget = currentTarget.parentElement as HTMLElement;
132
133         if (!currentTarget) {
134             break;
135         }
136
137         action = action || currentTarget.dataset.action;
138         id = id || currentTarget.dataset.id;
139     }
140
141     return [action, id];
142 };
143
144 export const Tree = withStyles(styles)(
145     class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
146         render(): ReactElement<any> {
147             const level = this.props.level ? this.props.level : 0;
148             const { classes, render, items, toggleItemActive, disableRipple, currentItemUuid, useRadioButtons } = this.props;
149             const { list, listItem, loader, toggableIconContainer, renderContainer, childItem, active, childItemIcon } = classes;
150             const showSelection = typeof this.props.showSelection === 'function'
151                 ? this.props.showSelection
152                 : () => this.props.showSelection ? true : false;
153
154             const { levelIndentation = 20, itemRightPadding = 20 } = this.props;
155
156             return <List className={list}>
157                 {items && items.map((it: TreeItem<T>, idx: number) =>
158                     <div key={`item/${level}/${it.id}`}>
159                         <ListItem button className={listItem}
160                             style={{
161                                 paddingLeft: (level + 1) * levelIndentation,
162                                 paddingRight: itemRightPadding,
163                             }}
164                             disableRipple={disableRipple}
165                             onClick={event => toggleItemActive(event, it)}
166                             selected={showSelection(it) && it.id === currentItemUuid}
167                             onContextMenu={(event) => this.props.onContextMenu(event, it)}>
168                             {it.status === TreeItemStatus.PENDING ?
169                                 <CircularProgress size={10} className={loader} /> : null}
170                             <i onClick={(e) => this.handleToggleItemOpen(it, e)}
171                                 className={toggableIconContainer}>
172                                 <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
173                                     {this.getProperArrowAnimation(it.status, it.items!)}
174                                 </ListItemIcon>
175                             </i>
176                             {showSelection(it) && !useRadioButtons &&
177                                 <Checkbox
178                                     checked={it.selected}
179                                     className={classes.checkbox}
180                                     color="primary"
181                                     onClick={this.handleCheckboxChange(it)} />}
182                             {showSelection(it) && useRadioButtons &&
183                                 <Radio
184                                     checked={it.selected}
185                                     className={classes.checkbox}
186                                     color="primary" />}
187                             <div className={renderContainer}>
188                                 {render(it, level)}
189                             </div>
190                         </ListItem>
191                         {it.open && it.items && it.items.length > 0 &&
192                             <div
193                                 onContextMenu={(event) => {
194                                     const [action, id] = getActionAndId(event, 'CONTEXT_MENU');
195                                     this.props.onContextMenu(event, { id } as any);
196                                 }}
197                                 onClick={(event) => {
198                                     const [action, id] = getActionAndId(event);
199
200                                     if (action && id) {
201                                         switch(action) {
202                                             case 'TOGGLE_OPEN':
203                                                 this.handleToggleItemOpen({ id } as any, event);
204                                                 break;
205                                             case 'TOGGLE_ACTIVE':
206                                                 toggleItemActive(event, { id } as any);
207                                                 break;
208                                             default:
209                                                 break;
210                                         }
211                                     }
212                                 }}
213                             >
214                                 {
215                                     it.items
216                                         .slice(0, 30)
217                                         .map((item: any) => <div key={item.id} data-id={item.id}
218                                             className={classnames(childItem, { [active]: item.active })}
219                                             style={{ paddingLeft: `${item.depth * levelIndentation}px`}}>
220                                             <i data-action="TOGGLE_OPEN" className={toggableIconContainer}>
221                                                 <ListItemIcon className={this.getToggableIconClassNames(item.open, item.active)}>
222                                                     {this.getProperArrowAnimation(item.status, item.items!)}
223                                                 </ListItemIcon>
224                                             </i>
225                                             <div data-action="TOGGLE_ACTIVE" className={renderContainer}>
226                                                 <span style={{ display: 'flex', alignItems: 'center' }}>
227                                                     <ProjectIcon className={classnames({ [active]: item.active }, childItemIcon)} />
228                                                     <span style={{ fontSize: '0.875rem' }}>
229                                                         {item.data.name}
230                                                     </span>
231                                                 </span>
232                                             </div>
233                                         </div>)
234                                 }
235                             </div>}
236                     </div>)}
237             </List>;
238         }
239
240         getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
241             return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
242         }
243
244         isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
245             return status === TreeItemStatus.PENDING ||
246                 (status === TreeItemStatus.LOADED && !items) ||
247                 (status === TreeItemStatus.LOADED && items && items.length === 0);
248         }
249
250         getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
251             const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
252             return classnames(toggableIcon, {
253                 [iconOpen]: isOpen,
254                 [iconClose]: !isOpen,
255                 [active]: isActive
256             });
257         }
258
259         handleCheckboxChange = (item: TreeItem<T>) => {
260             const { toggleItemSelection } = this.props;
261             return toggleItemSelection
262                 ? (event: React.MouseEvent<HTMLElement>) => {
263                     event.stopPropagation();
264                     toggleItemSelection(event, item);
265                 }
266                 : undefined;
267         }
268
269         handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
270             event.stopPropagation();
271             this.props.toggleItemOpen(event, item);
272         }
273     }
274 );