// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 import React, { useCallback, useState } from 'react'; import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core"; import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles'; import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon'; import { ReactElement } from "react"; import CircularProgress from '@material-ui/core/CircularProgress'; import classnames from "classnames"; import { ArvadosTheme } from 'common/custom-theme'; import { SidePanelRightArrowIcon } from '../icon/icon'; import { ResourceKind } from 'models/resource'; import { GroupClass } from 'models/group'; import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; type CssRules = 'list' | 'listItem' | 'active' | 'loader' | 'toggableIconContainer' | 'iconClose' | 'renderContainer' | 'iconOpen' | 'toggableIcon' | 'checkbox' | 'childItem' | 'childItemIcon' | 'frozenIcon' | 'indentSpacer'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ list: { padding: '3px 0px' }, listItem: { padding: '3px 0px', }, loader: { position: 'absolute', transform: 'translate(0px)', top: '3px' }, toggableIconContainer: { color: theme.palette.grey["700"], height: '14px', width: '14px', marginBottom: '0.4rem', }, toggableIcon: { fontSize: '14px', }, renderContainer: { flex: 1 }, iconClose: { transition: 'all 0.1s ease', }, iconOpen: { transition: 'all 0.1s ease', transform: 'rotate(90deg)', }, checkbox: { width: theme.spacing.unit * 3, height: theme.spacing.unit * 3, margin: `0 ${theme.spacing.unit}px`, padding: 0, color: theme.palette.grey["500"], }, childItem: { cursor: 'pointer', display: 'flex', padding: '3px 20px', fontSize: '0.875rem', alignItems: 'center', '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.08)', } }, childItemIcon: { marginLeft: '8px', marginRight: '16px', color: 'rgba(0, 0, 0, 0.54)', }, active: { color: theme.palette.primary.main, }, frozenIcon: { fontSize: 20, color: theme.palette.grey["600"], marginLeft: '10px', }, indentSpacer: { width: '0.25rem' } }); export enum TreeItemStatus { INITIAL = 'initial', PENDING = 'pending', LOADED = 'loaded' } export interface TreeItem { data: T; depth?: number; id: string; open: boolean; active: boolean; selected?: boolean; initialState?: boolean; indeterminate?: boolean; flatTree?: boolean; status: TreeItemStatus; items?: Array>; isFrozen?: boolean; } export interface TreeProps { disableRipple?: boolean; currentItemUuid?: string; items?: Array>; level?: number; itemsMap?: Map>; onContextMenu: (event: React.MouseEvent, item: TreeItem) => void; render: (item: TreeItem, level?: number) => ReactElement<{}>; showSelection?: boolean | ((item: TreeItem) => boolean); levelIndentation?: number; itemRightPadding?: number; toggleItemActive: (event: React.MouseEvent, item: TreeItem) => void; toggleItemOpen: (event: React.MouseEvent, item: TreeItem) => void; toggleItemSelection?: (event: React.MouseEvent, item: TreeItem) => void; selectedRef?: (node: HTMLDivElement | null) => void; /** * When set to true use radio buttons instead of checkboxes for item selection. * This does not guarantee radio group behavior (i.e item mutual exclusivity). * Any item selection logic must be done in the toggleItemActive callback prop. */ useRadioButtons?: boolean; } const getActionAndId = (event: any, initAction: string | undefined = undefined) => { const { nativeEvent: { target } } = event; let currentTarget: HTMLElement = target as HTMLElement; let action: string | undefined = initAction || currentTarget.dataset.action; let id: string | undefined = currentTarget.dataset.id; while (action === undefined || id === undefined) { currentTarget = currentTarget.parentElement as HTMLElement; if (!currentTarget) { break; } action = action || currentTarget.dataset.action; id = id || currentTarget.dataset.id; } return [action, id]; }; const isInFavoritesTree = (item: TreeItem): boolean => { return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES; } interface FlatTreeProps { it: TreeItem; levelIndentation: number; onContextMenu: Function; handleToggleItemOpen: Function; toggleItemActive: Function; getToggableIconClassNames: Function; getProperArrowAnimation: Function; itemsMap?: Map>; classes: any; showSelection: any; useRadioButtons?: boolean; handleCheckboxChange: Function; selectedRef?: (node: HTMLDivElement | null) => void; } const FLAT_TREE_ACTIONS = { toggleOpen: 'TOGGLE_OPEN', contextMenu: 'CONTEXT_MENU', toggleActive: 'TOGGLE_ACTIVE', }; const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => { let Icon = ProjectIcon; if (groupClass === GroupClass.FILTER) { Icon = FilterGroupIcon; } if (type) { switch (type) { case 'directory': Icon = DirectoryIcon; break; case 'file': Icon = FileIcon; break; default: Icon = DefaultIcon; } } if (kind) { if(kind === ResourceKind.LINK && headKind) kind = headKind; switch (kind) { case ResourceKind.COLLECTION: Icon = CollectionIcon; break; case ResourceKind.CONTAINER_REQUEST: Icon = ProcessIcon; break; default: break; } } return ; }); const FlatTree = (props: FlatTreeProps) =>
{ const id = getActionAndId(event, FLAT_TREE_ACTIONS.contextMenu)[1]; props.onContextMenu(event, { id } as any); }} onClick={(event) => { const [action, id] = getActionAndId(event); if (action && id) { const item = props.itemsMap ? props.itemsMap[id] : { id }; switch (action) { case FLAT_TREE_ACTIONS.toggleOpen: props.handleToggleItemOpen(item as any, event); break; case FLAT_TREE_ACTIONS.toggleActive: props.toggleItemActive(event, item as any); break; default: break; } } }} > { (props.it.items || []) .map((item: any) =>
{isInFavoritesTree(props.it) ?
: {props.getProperArrowAnimation(item.status, item.items!)} } {props.showSelection(item) && !props.useRadioButtons && } {props.showSelection(item) && props.useRadioButtons && }
{item.data.name} { !!item.data.frozenByUuid ? : null }
) }
; export const Tree = withStyles(styles)( function(props: TreeProps & WithStyles) { const level = props.level ? props.level : 0; const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = props; const { list, listItem, loader, toggableIconContainer, renderContainer } = classes; const showSelection = typeof props.showSelection === 'function' ? props.showSelection : () => props.showSelection ? true : false; const getProperArrowAnimation = (status: string, items: Array>) => { return isSidePanelIconNotNeeded(status, items) ? : ; } const isSidePanelIconNotNeeded = (status: string, items: Array>) => { return status === TreeItemStatus.PENDING || (status === TreeItemStatus.LOADED && !items) || (status === TreeItemStatus.LOADED && items && items.length === 0); } const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => { const { iconOpen, iconClose, active, toggableIcon } = props.classes; return classnames(toggableIcon, { [iconOpen]: isOpen, [iconClose]: !isOpen, [active]: isActive }); } const handleCheckboxChange = (item: TreeItem) => { const { toggleItemSelection } = props; return toggleItemSelection ? (event: React.MouseEvent) => { event.stopPropagation(); toggleItemSelection(event, item); } : undefined; } const handleToggleItemOpen = (item: TreeItem, event: React.MouseEvent) => { event.stopPropagation(); props.toggleItemOpen(event, item); } // Scroll to selected item whenever it changes, accepts selectedRef from props for recursive trees const [cachedSelectedRef, setCachedRef] = useState(null) const selectedRef = props.selectedRef || useCallback((node: HTMLDivElement | null) => { if (node && node.scrollIntoView && node !== cachedSelectedRef) { node.scrollIntoView({ behavior: "smooth", block: "center" }); } setCachedRef(node); }, [cachedSelectedRef]); const { levelIndentation = 20, itemRightPadding = 20 } = props; return {items && items.map((it: TreeItem, idx: number) => { if (isInFavoritesTree(it) && it.open === true && it.items && it.items.length) { it = { ...it, items: it.items.filter(item => item.depth && item.depth < 3) } } return
toggleItemActive(event, it)} selected={showSelection(it) && it.id === currentItemUuid} onContextMenu={(event) => props.onContextMenu(event, it)}> {it.status === TreeItemStatus.PENDING ? : null} handleToggleItemOpen(it, e)} className={toggableIconContainer}> {getProperArrowAnimation(it.status, it.items!)} {showSelection(it) && !useRadioButtons && } {showSelection(it) && useRadioButtons && }
{render(it, level)}
{ it.open && it.items && it.items.length > 0 && it.flatTree ? : }
; })}
; } );