/** * Tree component: * - collapsible * - icons prefix * - context menu */ import { DirectionalHint, IButtonStyles, IconButton, IContextualMenuItemProps, IContextualMenuProps, } from "@fluentui/react"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import * as React from "react"; import AnimateHeight from "react-animate-height"; import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif"; import TriangleDownIcon from "../../../../images/Triangle-down.svg"; import TriangleRightIcon from "../../../../images/Triangle-right.svg"; import * as Constants from "../../../Common/Constants"; import { StyleConstants } from "../../../Common/StyleConstants"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; export interface LegacyTreeNode { label: string; id?: string; children?: LegacyTreeNode[]; contextMenu?: TreeNodeMenuItem[]; iconSrc?: string; isExpanded?: boolean; className?: string; isAlphaSorted?: boolean; data?: any; // Piece of data corresponding to this node timestamp?: number; isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves isLoading?: boolean; isSelected?: () => boolean; onClick?: (isExpanded: boolean) => void; // Only if a leaf, other click will expand/collapse onExpanded?: () => void; onCollapsed?: () => void; onContextMenuOpen?: () => void; } export interface LegacyTreeComponentProps { rootNode: LegacyTreeNode; style?: any; className?: string; } export class LegacyTreeComponent extends React.Component<LegacyTreeComponentProps> { public render(): JSX.Element { return ( <div style={this.props.style} className={`treeComponent ${this.props.className}`} role="tree"> <LegacyTreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} /> </div> ); } } /* Tree node is a react component */ interface LegacyTreeNodeComponentProps { node: LegacyTreeNode; generation: number; paddingLeft: number; } interface LegacyTreeNodeComponentState { isExpanded: boolean; isMenuShowing: boolean; } export class LegacyTreeNodeComponent extends React.Component< LegacyTreeNodeComponentProps, LegacyTreeNodeComponentState > { private static readonly paddingPerGenerationPx = 16; private static readonly iconOffset = 22; private static readonly transitionDurationMS = 200; private static readonly callbackDelayMS = 100; // avoid calling at the same time as transition to make it smoother private contextMenuRef = React.createRef<HTMLDivElement>(); private isExpanded: boolean; constructor(props: LegacyTreeNodeComponentProps) { super(props); this.isExpanded = props.node.isExpanded; this.state = { isExpanded: props.node.isExpanded, isMenuShowing: false, }; } componentDidUpdate(prevProps: LegacyTreeNodeComponentProps, prevState: LegacyTreeNodeComponentState) { // Only call when expand has actually changed if (this.state.isExpanded !== prevState.isExpanded) { if (this.state.isExpanded) { this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, LegacyTreeNodeComponent.callbackDelayMS); } else { this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, LegacyTreeNodeComponent.callbackDelayMS); } } if (this.props.node.isExpanded !== this.isExpanded) { this.isExpanded = this.props.node.isExpanded; this.setState({ isExpanded: this.props.node.isExpanded, }); } } public render(): JSX.Element { return this.renderNode(this.props.node, this.props.generation); } private static getSortedChildren(treeNode: LegacyTreeNode): LegacyTreeNode[] { if (!treeNode || !treeNode.children) { return undefined; } const compareFct = (a: LegacyTreeNode, b: LegacyTreeNode) => a.label.localeCompare(b.label); let unsortedChildren; if (treeNode.isLeavesParentsSeparate) { // Separate parents and leave const parents: LegacyTreeNode[] = treeNode.children.filter((node) => node.children); const leaves: LegacyTreeNode[] = treeNode.children.filter((node) => !node.children); if (treeNode.isAlphaSorted) { parents.sort(compareFct); leaves.sort(compareFct); } unsortedChildren = parents.concat(leaves); } else { unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children; } return unsortedChildren; } private static isNodeHeaderBlank(node: LegacyTreeNode): boolean { return (node.label === undefined || node.label === null) && !node.contextMenu; } private renderNode(node: LegacyTreeNode, generation: number): JSX.Element { const paddingLeft = generation * LegacyTreeNodeComponent.paddingPerGenerationPx; let additionalOffsetPx = 15; if (node.children) { const childrenWithSubChildren = node.children.filter((child: LegacyTreeNode) => !!child.children); if (childrenWithSubChildren.length > 0) { additionalOffsetPx = LegacyTreeNodeComponent.iconOffset; } } // Don't show as selected if any of the children is selected const showSelected = this.props.node.isSelected && this.props.node.isSelected() && !LegacyTreeNodeComponent.isAnyDescendantSelected(this.props.node); const headerStyle: React.CSSProperties = { paddingLeft: this.props.paddingLeft }; if (LegacyTreeNodeComponent.isNodeHeaderBlank(node)) { headerStyle.height = 0; headerStyle.padding = 0; } return ( <div data-test={`Tree/TreeNode:${node.label}`} className={`${this.props.node.className || ""} main${generation} nodeItem ${showSelected ? "selected" : ""}`} onClick={(event: React.MouseEvent<HTMLDivElement>) => this.onNodeClick(event, node)} onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => this.onNodeKeyPress(event, node)} role="treeitem" id={node.id} > <div className={`treeNodeHeader ${this.state.isMenuShowing ? "showingMenu" : ""}`} data-test={`Tree/TreeNode/Header:${node.label}`} style={headerStyle} tabIndex={node.children ? -1 : 0} > {this.renderCollapseExpandIcon(node)} {node.iconSrc && <img className="nodeIcon" src={node.iconSrc} alt="" />} {node.label && ( <span className="nodeLabel" title={node.label}> {node.label} </span> )} {node.contextMenu && this.renderContextMenuButton(node)} </div> <div className="loadingIconContainer"> <img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} /> </div> {node.children && ( <AnimateHeight duration={LegacyTreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0} > <div className="nodeChildren" data-test={node.label} role="group"> {LegacyTreeNodeComponent.getSortedChildren(node).map((childNode: LegacyTreeNode) => ( <LegacyTreeNodeComponent key={`${childNode.label}-${generation + 1}-${childNode.timestamp}`} node={childNode} generation={generation + 1} paddingLeft={paddingLeft + (!childNode.children && !childNode.iconSrc ? additionalOffsetPx : 0)} /> ))} </div> </AnimateHeight> )} </div> ); } /** * Recursive: is the node or any descendant selected * @param node */ private static isAnyDescendantSelected(node: LegacyTreeNode): boolean { return ( node.children && node.children.reduce( (previous: boolean, child: LegacyTreeNode) => previous || (child.isSelected && child.isSelected()) || LegacyTreeNodeComponent.isAnyDescendantSelected(child), false, ) ); } private static createClickEvent(): MouseEvent { return new MouseEvent("click", { bubbles: true, view: window, cancelable: true }); } private onRightClick = (): void => { this.contextMenuRef.current.firstChild.dispatchEvent(LegacyTreeNodeComponent.createClickEvent()); }; private renderContextMenuButton(node: LegacyTreeNode): JSX.Element { const menuItemLabel = "More"; const buttonStyles: Partial<IButtonStyles> = { rootFocused: { outline: `1px dashed ${StyleConstants.FocusColor}` }, }; return ( <div ref={this.contextMenuRef} onContextMenu={this.onRightClick} onKeyPress={this.onMoreButtonKeyPress}> <IconButton name="More" title="More" className="treeMenuEllipsis" ariaLabel={`${menuItemLabel} options`} menuIconProps={{ iconName: menuItemLabel, styles: { root: { fontSize: "18px", fontWeight: "bold" } }, }} menuProps={{ coverTarget: true, isBeakVisible: false, directionalHint: DirectionalHint.topAutoEdge, onMenuOpened: (contextualMenu?: IContextualMenuProps) => { this.setState({ isMenuShowing: true }); node.onContextMenuOpen && node.onContextMenuOpen(); }, onMenuDismissed: (contextualMenu?: IContextualMenuProps) => this.setState({ isMenuShowing: false }), contextualMenuItemAs: (props: IContextualMenuItemProps) => ( <div data-test={`Tree/TreeNode/MenuItem:${props.item.text}`} className="treeComponentMenuItemContainer" onContextMenu={(e) => e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())} > {props.item.onRenderIcon()} <span className={ "treeComponentMenuItemLabel" + (props.item.className ? ` ${props.item.className}Label` : "") } > {props.item.text} </span> </div> ), items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({ key: menuItem.label, text: menuItem.label, disabled: menuItem.isDisabled, className: menuItem.styleClass, onClick: () => { menuItem.onClick(); TelemetryProcessor.trace(Action.ClickResourceTreeNodeContextMenuItem, ActionModifiers.Mark, { label: menuItem.label, }); }, onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" />, })), }} styles={buttonStyles} /> </div> ); } private renderCollapseExpandIcon(node: LegacyTreeNode): JSX.Element { if (!node.children || !node.label) { return <></>; } return ( <img className="expandCollapseIcon" src={this.state.isExpanded ? TriangleDownIcon : TriangleRightIcon} alt={this.state.isExpanded ? `${node.label} branch is expanded` : `${node.label} branch is collapsed`} onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => this.onCollapseExpandIconKeyPress(event, node)} tabIndex={0} role="button" /> ); } private onNodeClick = (event: React.MouseEvent<HTMLDivElement>, node: LegacyTreeNode): void => { event.stopPropagation(); if (node.children) { const isExpanded = !this.state.isExpanded; // Prevent collapsing if node header is blank if (!(LegacyTreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) { this.setState({ isExpanded }); } } this.props.node.onClick && this.props.node.onClick(this.state.isExpanded); }; private onNodeKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { event.stopPropagation(); this.props.node.onClick && this.props.node.onClick(this.state.isExpanded); } }; private onCollapseExpandIconKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { event.stopPropagation(); if (node.children) { this.setState({ isExpanded: !this.state.isExpanded }); } } }; private onMoreButtonKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { event.stopPropagation(); } }; }