mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-03-13 05:15:30 +00:00
354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
};
|
|
}
|