/** * 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 { public render(): JSX.Element { return (
); } } /* 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(); 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 (
) => this.onNodeClick(event, node)} onKeyPress={(event: React.KeyboardEvent) => this.onNodeKeyPress(event, node)} role="treeitem" id={node.id} >
{this.renderCollapseExpandIcon(node)} {node.iconSrc && } {node.label && ( {node.label} )} {node.contextMenu && this.renderContextMenuButton(node)}
{node.children && (
{LegacyTreeNodeComponent.getSortedChildren(node).map((childNode: LegacyTreeNode) => ( ))}
)}
); } /** * 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 = { rootFocused: { outline: `1px dashed ${StyleConstants.FocusColor}` }, }; return (
{ this.setState({ isMenuShowing: true }); node.onContextMenuOpen && node.onContextMenuOpen(); }, onMenuDismissed: (contextualMenu?: IContextualMenuProps) => this.setState({ isMenuShowing: false }), contextualMenuItemAs: (props: IContextualMenuItemProps) => (
e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())} > {props.item.onRenderIcon()} {props.item.text}
), 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) => , })), }} styles={buttonStyles} />
); } private renderCollapseExpandIcon(node: LegacyTreeNode): JSX.Element { if (!node.children || !node.label) { return <>; } return ( {this.state.isExpanded) => this.onCollapseExpandIconKeyPress(event, node)} tabIndex={0} role="button" /> ); } private onNodeClick = (event: React.MouseEvent, 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, 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, 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): void => { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { event.stopPropagation(); } }; }