1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { useCallback, useState } from 'react';
6 import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@mui/material";
7 import { CustomStyleRulesCallback } from 'common/custom-theme';
8 import { WithStyles } from '@mui/styles';
9 import withStyles from '@mui/styles/withStyles';
10 import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
11 import { ReactElement } from "react";
12 import CircularProgress from '@mui/material/CircularProgress';
13 import classnames from "classnames";
15 import { ArvadosTheme } from 'common/custom-theme';
16 import { SidePanelRightArrowIcon } from '../icon/icon';
17 import { ResourceKind } from 'models/resource';
18 import { GroupClass } from 'models/group';
19 import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
20 import { kebabCase } from 'lodash';
21 import { TreeItemWeight } from 'store/tree-picker/tree-picker';
23 type CssRules = 'list'
28 | 'childItemNameLight'
31 | 'toggableIconContainer'
42 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
51 transform: 'translate(0px)',
54 toggableIconContainer: {
55 color: theme.palette.grey["700"],
58 marginBottom: '0.4rem',
69 transition: 'all 0.1s ease',
72 transition: 'all 0.1s ease',
73 transform: 'rotate(90deg)',
76 width: theme.spacing(3),
77 height: theme.spacing(3),
78 margin: `0 ${theme.spacing(1)}`,
80 color: theme.palette.grey["500"],
89 backgroundColor: 'rgba(0, 0, 0, 0.08)',
102 color: 'rgba(0, 0, 0, 0.54)',
105 color: theme.palette.primary.main,
108 color: theme.customs.colors.greyL,
115 color: theme.palette.grey["600"],
123 export enum TreeItemStatus {
129 export interface TreeItem<T> {
136 initialState?: boolean;
137 indeterminate?: boolean;
139 status: TreeItemStatus;
140 items?: Array<TreeItem<T>>;
144 export interface TreeProps<T> {
145 disableRipple?: boolean;
146 currentItemUuid?: string;
147 items?: Array<TreeItem<T>>;
149 itemsMap?: Map<string, TreeItem<T>>;
150 onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
151 render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
152 showSelection?: boolean | ((item: TreeItem<T>) => boolean);
153 levelIndentation?: number;
154 itemRightPadding?: number;
155 toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
156 toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
157 toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
158 selectedRef?: (node: HTMLDivElement | null) => void;
161 * When set to true use radio buttons instead of checkboxes for item selection.
162 * This does not guarantee radio group behavior (i.e item mutual exclusivity).
163 * Any item selection logic must be done in the toggleItemActive callback prop.
165 useRadioButtons?: boolean;
168 const getActionAndId = (event: any, initAction: string | undefined = undefined) => {
169 const { nativeEvent: { target } } = event;
170 let currentTarget: HTMLElement = target as HTMLElement;
171 let action: string | undefined = initAction || currentTarget.dataset.action;
172 let id: string | undefined = currentTarget.dataset.id;
174 while (action === undefined || id === undefined) {
175 currentTarget = currentTarget.parentElement as HTMLElement;
177 if (!currentTarget) {
181 action = action || currentTarget.dataset.action;
182 id = id || currentTarget.dataset.id;
188 const isInFavoritesTree = (item: TreeItem<any>): boolean => {
189 return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES;
192 interface FlatTreeProps {
194 levelIndentation: number;
195 onContextMenu: Function;
196 handleToggleItemOpen: Function;
197 toggleItemActive: Function;
198 getToggableIconClassNames: Function;
199 getProperArrowAnimation: Function;
200 itemsMap?: Map<string, TreeItem<any>>;
203 useRadioButtons?: boolean;
204 handleCheckboxChange: Function;
205 selectedRef?: (node: HTMLDivElement | null) => void;
208 const FLAT_TREE_ACTIONS = {
209 toggleOpen: 'TOGGLE_OPEN',
210 contextMenu: 'CONTEXT_MENU',
211 toggleActive: 'TOGGLE_ACTIVE',
214 const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => {
215 let Icon = ProjectIcon;
217 if (groupClass === GroupClass.FILTER) {
218 Icon = FilterGroupIcon;
224 Icon = DirectoryIcon;
235 if(kind === ResourceKind.LINK && headKind) kind = headKind;
237 case ResourceKind.COLLECTION:
238 Icon = CollectionIcon;
240 case ResourceKind.CONTAINER_REQUEST:
248 return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
251 const FlatTree = (props: FlatTreeProps) =>
253 onContextMenu={(event) => {
254 const id = getActionAndId(event, FLAT_TREE_ACTIONS.contextMenu)[1];
255 props.onContextMenu(event, { id } as any);
257 onClick={(event) => {
258 const [action, id] = getActionAndId(event);
261 const item = props.itemsMap ? props.itemsMap[id] : { id };
264 case FLAT_TREE_ACTIONS.toggleOpen:
265 props.handleToggleItemOpen(item as any, event);
267 case FLAT_TREE_ACTIONS.toggleActive:
268 props.toggleItemActive(event, item as any);
277 (props.it.items || [])
278 .map((item: any, index: number) => <div key={item.id || index} data-id={item.id}
279 className={classnames(props.classes.childItem, {
280 [props.classes.active]: item.active,
281 [props.classes.itemWeightLight]: (item.data.weight === TreeItemWeight.LIGHT && !item.active),
282 [props.classes.itemWeightDark]: (item.data.weight === TreeItemWeight.DARK && !item.active),
284 style={{ paddingLeft: `${item.depth * props.levelIndentation}px` }}>
285 {isInFavoritesTree(props.it) ?
286 <div className={props.classes.indentSpacer} />
288 <i data-action={FLAT_TREE_ACTIONS.toggleOpen} className={props.classes.toggableIconContainer}>
289 <ListItemIcon className={props.getToggableIconClassNames(item.open, item.active)}>
290 {props.getProperArrowAnimation(item.status, item.items!)}
293 {props.showSelection(item) && !props.useRadioButtons &&
295 checked={item.selected}
296 className={props.classes.checkbox}
298 onClick={props.handleCheckboxChange(item)} />}
299 {props.showSelection(item) && props.useRadioButtons &&
301 checked={item.selected}
302 className={props.classes.checkbox}
304 <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer} ref={item.active ? props.selectedRef : undefined}>
305 <span className={props.classes.childLi}>
306 <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} />
307 <span className={props.classes.childItemName}>
311 !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
319 export const Tree = withStyles(styles)(
320 function<T>(props: TreeProps<T> & WithStyles<CssRules>) {
321 const level = props.level ? props.level : 0;
322 const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = props;
323 const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
324 const showSelection = typeof props.showSelection === 'function'
325 ? props.showSelection
326 : () => props.showSelection ? true : false;
328 const getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
329 return isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} data-cy="side-panel-arrow-icon" />;
332 const isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
333 return status === TreeItemStatus.PENDING ||
334 (status === TreeItemStatus.LOADED && !items) ||
335 (status === TreeItemStatus.LOADED && items && items.length === 0);
338 const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
339 const { iconOpen, iconClose, active, toggableIcon } = props.classes;
340 return classnames(toggableIcon, {
342 [iconClose]: !isOpen,
347 const handleCheckboxChange = (item: TreeItem<T>) => {
348 const { toggleItemSelection } = props;
349 return toggleItemSelection
350 ? (event: React.MouseEvent<HTMLElement>) => {
351 event.stopPropagation();
352 toggleItemSelection(event, item);
357 const handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
358 event.stopPropagation();
359 props.toggleItemOpen(event, item);
362 // Scroll to selected item whenever it changes, accepts selectedRef from props for recursive trees
363 const [cachedSelectedRef, setCachedRef] = useState<HTMLDivElement | null>(null)
364 const selectedRef = props.selectedRef || useCallback((node: HTMLDivElement | null) => {
365 if (node && node.scrollIntoView && node !== cachedSelectedRef) {
366 node.scrollIntoView({ behavior: "smooth", block: "center" });
369 }, [cachedSelectedRef]);
371 const { levelIndentation = 20, itemRightPadding = 20 } = props;
372 return <List className={list}>
373 {items && items.map((it: TreeItem<T>, idx: number) => {
374 if (isInFavoritesTree(it) && it.open === true && it.items && it.items.length) {
375 it = { ...it, items: it.items.filter(item => item.depth && item.depth < 3) }
377 return <div key={`item/${level}/${it.id}`}>
378 <ListItem button className={listItem}
381 paddingLeft: (level + 1) * levelIndentation,
382 paddingRight: itemRightPadding,
384 disableRipple={disableRipple}
385 onClick={event => toggleItemActive(event, it)}
386 selected={showSelection(it) && it.id === currentItemUuid}
387 onContextMenu={(event) => props.onContextMenu(event, it)}>
388 {it.status === TreeItemStatus.PENDING ?
389 <CircularProgress size={10} className={loader} /> : null}
390 <i onClick={(e) => handleToggleItemOpen(it, e)}
391 className={toggableIconContainer}>
392 <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}
393 data-cy={`tree-item-toggle-${kebabCase(it.id.toString())}`}
395 {getProperArrowAnimation(it.status, it.items!)}
398 {showSelection(it) && !useRadioButtons &&
400 checked={it.selected}
401 indeterminate={!it.selected && it.indeterminate}
402 className={classes.checkbox}
404 onClick={handleCheckboxChange(it)} />}
405 {showSelection(it) && useRadioButtons &&
407 checked={it.selected}
408 className={classes.checkbox}
410 <div className={renderContainer} ref={!!it.active ? selectedRef : undefined}>
415 it.open && it.items && it.items.length > 0 &&
420 showSelection={showSelection}
421 classes={props.classes}
422 useRadioButtons={useRadioButtons}
423 levelIndentation={levelIndentation}
424 handleCheckboxChange={handleCheckboxChange}
425 onContextMenu={props.onContextMenu}
426 handleToggleItemOpen={handleToggleItemOpen}
427 toggleItemActive={props.toggleItemActive}
428 getToggableIconClassNames={getToggableIconClassNames}
429 getProperArrowAnimation={getProperArrowAnimation}
430 selectedRef={selectedRef}
432 <Collapse in={it.open} timeout="auto" unmountOnExit>
434 showSelection={props.showSelection}
437 disableRipple={disableRipple}
438 toggleItemOpen={toggleItemOpen}
439 toggleItemActive={toggleItemActive}
441 onContextMenu={props.onContextMenu}
442 toggleItemSelection={props.toggleItemSelection}
443 selectedRef={selectedRef}