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