21720: implemented CustomStyleRulesCallback and replaced everywhere
[arvados.git] / services / workbench2 / 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 React, { useCallback, useState } from 'react';
6 import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
7 import { CustomStyleRulesCallback } from 'common/custom-theme';
8 import { withStyles, WithStyles } from '@material-ui/core/styles';
9 import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
10 import { ReactElement } from "react";
11 import CircularProgress from '@material-ui/core/CircularProgress';
12 import classnames from "classnames";
13
14 import { ArvadosTheme } from 'common/custom-theme';
15 import { SidePanelRightArrowIcon } from '../icon/icon';
16 import { ResourceKind } from 'models/resource';
17 import { GroupClass } from 'models/group';
18 import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
19
20 type CssRules = 'list'
21     | 'listItem'
22     | 'active'
23     | 'loader'
24     | 'toggableIconContainer'
25     | 'iconClose'
26     | 'renderContainer'
27     | 'iconOpen'
28     | 'toggableIcon'
29     | 'checkbox'
30     | 'childItem'
31     | 'childItemIcon'
32     | 'frozenIcon'
33     | 'indentSpacer';
34
35 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
36     list: {
37         padding: '3px 0px'
38     },
39     listItem: {
40         padding: '3px 0px',
41     },
42     loader: {
43         position: 'absolute',
44         transform: 'translate(0px)',
45         top: '3px'
46     },
47     toggableIconContainer: {
48         color: theme.palette.grey["700"],
49         height: '14px',
50         width: '14px',
51         marginBottom: '0.4rem',
52     },
53     toggableIcon: {
54         fontSize: '14px',
55     },
56     renderContainer: {
57         flex: 1
58     },
59     iconClose: {
60         transition: 'all 0.1s ease',
61     },
62     iconOpen: {
63         transition: 'all 0.1s ease',
64         transform: 'rotate(90deg)',
65     },
66     checkbox: {
67         width: theme.spacing.unit * 3,
68         height: theme.spacing.unit * 3,
69         margin: `0 ${theme.spacing.unit}px`,
70         padding: 0,
71         color: theme.palette.grey["500"],
72     },
73     childItem: {
74         cursor: 'pointer',
75         display: 'flex',
76         padding: '3px 20px',
77         fontSize: '0.875rem',
78         alignItems: 'center',
79         '&:hover': {
80             backgroundColor: 'rgba(0, 0, 0, 0.08)',
81         }
82     },
83     childItemIcon: {
84         marginLeft: '8px',
85         marginRight: '16px',
86         color: 'rgba(0, 0, 0, 0.54)',
87     },
88     active: {
89         color: theme.palette.primary.main,
90     },
91     frozenIcon: {
92         fontSize: 20,
93         color: theme.palette.grey["600"],
94         marginLeft: '10px',
95     },
96     indentSpacer: {
97         width: '0.25rem'
98     }
99 });
100
101 export enum TreeItemStatus {
102     INITIAL = 'initial',
103     PENDING = 'pending',
104     LOADED = 'loaded'
105 }
106
107 export interface TreeItem<T> {
108     data: T;
109     depth?: number;
110     id: string;
111     open: boolean;
112     active: boolean;
113     selected?: boolean;
114     initialState?: boolean;
115     indeterminate?: boolean;
116     flatTree?: boolean;
117     status: TreeItemStatus;
118     items?: Array<TreeItem<T>>;
119     isFrozen?: boolean;
120 }
121
122 export interface TreeProps<T> {
123     disableRipple?: boolean;
124     currentItemUuid?: string;
125     items?: Array<TreeItem<T>>;
126     level?: number;
127     itemsMap?: Map<string, TreeItem<T>>;
128     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
129     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
130     showSelection?: boolean | ((item: TreeItem<T>) => boolean);
131     levelIndentation?: number;
132     itemRightPadding?: number;
133     toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
134     toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
135     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
136     selectedRef?: (node: HTMLDivElement | null) => void;
137
138     /**
139      * When set to true use radio buttons instead of checkboxes for item selection.
140      * This does not guarantee radio group behavior (i.e item mutual exclusivity).
141      * Any item selection logic must be done in the toggleItemActive callback prop.
142      */
143     useRadioButtons?: boolean;
144 }
145
146 const getActionAndId = (event: any, initAction: string | undefined = undefined) => {
147     const { nativeEvent: { target } } = event;
148     let currentTarget: HTMLElement = target as HTMLElement;
149     let action: string | undefined = initAction || currentTarget.dataset.action;
150     let id: string | undefined = currentTarget.dataset.id;
151
152     while (action === undefined || id === undefined) {
153         currentTarget = currentTarget.parentElement as HTMLElement;
154
155         if (!currentTarget) {
156             break;
157         }
158
159         action = action || currentTarget.dataset.action;
160         id = id || currentTarget.dataset.id;
161     }
162
163     return [action, id];
164 };
165
166 const isInFavoritesTree = (item: TreeItem<any>): boolean => {
167     return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES;
168 }
169
170 interface FlatTreeProps {
171     it: TreeItem<any>;
172     levelIndentation: number;
173     onContextMenu: Function;
174     handleToggleItemOpen: Function;
175     toggleItemActive: Function;
176     getToggableIconClassNames: Function;
177     getProperArrowAnimation: Function;
178     itemsMap?: Map<string, TreeItem<any>>;
179     classes: any;
180     showSelection: any;
181     useRadioButtons?: boolean;
182     handleCheckboxChange: Function;
183     selectedRef?: (node: HTMLDivElement | null) => void;
184 }
185
186 const FLAT_TREE_ACTIONS = {
187     toggleOpen: 'TOGGLE_OPEN',
188     contextMenu: 'CONTEXT_MENU',
189     toggleActive: 'TOGGLE_ACTIVE',
190 };
191
192 const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => {
193     let Icon = ProjectIcon;
194
195     if (groupClass === GroupClass.FILTER) {
196         Icon = FilterGroupIcon;
197     }
198
199     if (type) {
200         switch (type) {
201             case 'directory':
202                 Icon = DirectoryIcon;
203                 break;
204             case 'file':
205                 Icon = FileIcon;
206                 break;
207             default:
208                 Icon = DefaultIcon;
209         }
210     }
211
212     if (kind) {
213         if(kind === ResourceKind.LINK && headKind) kind = headKind;
214         switch (kind) {
215             case ResourceKind.COLLECTION:
216                 Icon = CollectionIcon;
217                 break;
218             case ResourceKind.CONTAINER_REQUEST:
219                 Icon = ProcessIcon;
220                 break;
221             default:
222                 break;
223         }
224     }
225
226     return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
227 });
228
229 const FlatTree = (props: FlatTreeProps) =>
230     <div
231         onContextMenu={(event) => {
232             const id = getActionAndId(event, FLAT_TREE_ACTIONS.contextMenu)[1];
233             props.onContextMenu(event, { id } as any);
234         }}
235         onClick={(event) => {
236             const [action, id] = getActionAndId(event);
237
238             if (action && id) {
239                 const item = props.itemsMap ? props.itemsMap[id] : { id };
240
241                 switch (action) {
242                     case FLAT_TREE_ACTIONS.toggleOpen:
243                         props.handleToggleItemOpen(item as any, event);
244                         break;
245                     case FLAT_TREE_ACTIONS.toggleActive:
246                         props.toggleItemActive(event, item as any);
247                         break;
248                     default:
249                         break;
250                 }
251             }
252         }}
253     >
254         {
255             (props.it.items || [])
256                 .map((item: any, index: number) => <div key={item.id || index} data-id={item.id}
257                     className={classnames(props.classes.childItem, { [props.classes.active]: item.active })}
258                     style={{ paddingLeft: `${item.depth * props.levelIndentation}px` }}>
259                     {isInFavoritesTree(props.it) ? 
260                         <div className={props.classes.indentSpacer} />
261                         :
262                         <i data-action={FLAT_TREE_ACTIONS.toggleOpen} className={props.classes.toggableIconContainer}>
263                             <ListItemIcon className={props.getToggableIconClassNames(item.open, item.active)}>
264                                 {props.getProperArrowAnimation(item.status, item.items!)}
265                             </ListItemIcon> 
266                         </i>}
267                     {props.showSelection(item) && !props.useRadioButtons &&
268                         <Checkbox
269                             checked={item.selected}
270                             className={props.classes.checkbox}
271                             color="primary"
272                             onClick={props.handleCheckboxChange(item)} />}
273                     {props.showSelection(item) && props.useRadioButtons &&
274                         <Radio
275                             checked={item.selected}
276                             className={props.classes.checkbox}
277                             color="primary" />}
278                     <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer} ref={item.active ? props.selectedRef : undefined}>
279                         <span style={{ display: 'flex', alignItems: 'center' }}>
280                             <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} headKind={item.data.headKind || null} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
281                             <span style={{ fontSize: '0.875rem' }}>
282                                 {item.data.name}
283                             </span>
284                             {
285                                 !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
286                             }
287                         </span>
288                     </div>
289                 </div>)
290         }
291     </div>;
292
293 export const Tree = withStyles(styles)(
294     function<T>(props: TreeProps<T> & WithStyles<CssRules>) {
295         const level = props.level ? props.level : 0;
296         const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = props;
297         const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
298         const showSelection = typeof props.showSelection === 'function'
299             ? props.showSelection
300             : () => props.showSelection ? true : false;
301
302         const getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
303             return isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
304         }
305
306         const isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
307             return status === TreeItemStatus.PENDING ||
308                 (status === TreeItemStatus.LOADED && !items) ||
309                 (status === TreeItemStatus.LOADED && items && items.length === 0);
310         }
311
312         const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
313             const { iconOpen, iconClose, active, toggableIcon } = props.classes;
314             return classnames(toggableIcon, {
315                 [iconOpen]: isOpen,
316                 [iconClose]: !isOpen,
317                 [active]: isActive
318             });
319         }
320
321         const handleCheckboxChange = (item: TreeItem<T>) => {
322             const { toggleItemSelection } = props;
323             return toggleItemSelection
324                 ? (event: React.MouseEvent<HTMLElement>) => {
325                     event.stopPropagation();
326                     toggleItemSelection(event, item);
327                 }
328                 : undefined;
329         }
330
331         const handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
332             event.stopPropagation();
333             props.toggleItemOpen(event, item);
334         }
335
336         // Scroll to selected item whenever it changes, accepts selectedRef from props for recursive trees
337         const [cachedSelectedRef, setCachedRef] = useState<HTMLDivElement | null>(null)
338         const selectedRef = props.selectedRef || useCallback((node: HTMLDivElement | null) => {
339             if (node && node.scrollIntoView && node !== cachedSelectedRef) {
340                 node.scrollIntoView({ behavior: "smooth", block: "center" });
341             }
342             setCachedRef(node);
343         }, [cachedSelectedRef]);
344
345         const { levelIndentation = 20, itemRightPadding = 20 } = props;
346         return <List className={list}>
347             {items && items.map((it: TreeItem<T>, idx: number) => {
348                 if (isInFavoritesTree(it) && it.open === true && it.items && it.items.length) {
349                     it = { ...it, items: it.items.filter(item => item.depth && item.depth < 3) }
350                 }
351                 return <div key={`item/${level}/${it.id}`}>
352                     <ListItem button className={listItem}
353                         style={{
354                             paddingLeft: (level + 1) * levelIndentation,
355                             paddingRight: itemRightPadding,
356                         }}
357                         disableRipple={disableRipple}
358                         onClick={event => toggleItemActive(event, it)}
359                         selected={showSelection(it) && it.id === currentItemUuid}
360                         onContextMenu={(event) => props.onContextMenu(event, it)}>
361                         {it.status === TreeItemStatus.PENDING ?
362                             <CircularProgress size={10} className={loader} /> : null}
363                         <i onClick={(e) => handleToggleItemOpen(it, e)}
364                             className={toggableIconContainer}>
365                             <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}>
366                                 {getProperArrowAnimation(it.status, it.items!)}
367                             </ListItemIcon>
368                         </i>
369                         {showSelection(it) && !useRadioButtons &&
370                             <Checkbox
371                                 checked={it.selected}
372                                 indeterminate={!it.selected && it.indeterminate}
373                                 className={classes.checkbox}
374                                 color="primary"
375                                 onClick={handleCheckboxChange(it)} />}
376                         {showSelection(it) && useRadioButtons &&
377                             <Radio
378                                 checked={it.selected}
379                                 className={classes.checkbox}
380                                 color="primary" />}
381                         <div className={renderContainer} ref={!!it.active ? selectedRef : undefined}>
382                             {render(it, level)}
383                         </div>
384                     </ListItem>
385                     {
386                         it.open && it.items && it.items.length > 0 &&
387                             it.flatTree ?
388                             <FlatTree
389                                 it={it}
390                                 itemsMap={itemsMap}
391                                 showSelection={showSelection}
392                                 classes={props.classes}
393                                 useRadioButtons={useRadioButtons}
394                                 levelIndentation={levelIndentation}
395                                 handleCheckboxChange={handleCheckboxChange}
396                                 onContextMenu={props.onContextMenu}
397                                 handleToggleItemOpen={handleToggleItemOpen}
398                                 toggleItemActive={props.toggleItemActive}
399                                 getToggableIconClassNames={getToggableIconClassNames}
400                                 getProperArrowAnimation={getProperArrowAnimation}
401                                 selectedRef={selectedRef}
402                             /> :
403                             <Collapse in={it.open} timeout="auto" unmountOnExit>
404                                 <Tree
405                                     showSelection={props.showSelection}
406                                     items={it.items}
407                                     render={render}
408                                     disableRipple={disableRipple}
409                                     toggleItemOpen={toggleItemOpen}
410                                     toggleItemActive={toggleItemActive}
411                                     level={level + 1}
412                                     onContextMenu={props.onContextMenu}
413                                     toggleItemSelection={props.toggleItemSelection}
414                                     selectedRef={selectedRef}
415                                 />
416                             </Collapse>
417                     }
418                 </div>;
419             })}
420         </List>;
421     }
422 );