import { useState, useRef, useMemo, useEffect } from 'react'; import { observer } from 'mobx-react'; import { css } from '@patternfly/react-styles'; import { Tooltip, TooltipPosition } from '@patternfly/react-core'; import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon'; import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; import ExclamationTriangleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon'; import styles from '../../css/topology-components'; import { BadgeLocation, GraphElement, isNode, LabelPosition, Node, NodeStatus, TopologyQuadrant } from '../../types'; import { ConnectDragSource, ConnectDropTarget, OnSelect, WithDndDragProps } from '../../behavior'; import Decorator from '../decorators/Decorator'; import { Layer } from '../layers'; import { TOP_LAYER } from '../../const'; import { createSvgIdUrl, StatusModifier, useCombineRefs, useHover } from '../../utils'; import NodeLabel from './labels/NodeLabel'; import NodeShadows, { NODE_SHADOW_FILTER_ID_DANGER, NODE_SHADOW_FILTER_ID_HOVER } from './NodeShadows'; import { DEFAULT_DECORATOR_RADIUS, getDefaultShapeDecoratorCenter, getShapeComponent, ShapeProps } from './shapes'; const StatusQuadrant = TopologyQuadrant.upperLeft; const getStatusIcon = (status: NodeStatus) => { switch (status) { case NodeStatus.danger: return ; case NodeStatus.warning: return ; case NodeStatus.success: return ; default: return null; } }; interface DefaultNodeProps { /** Additional content added to the node */ children?: React.ReactNode; /** Additional classes added to the node */ className?: string; /** The graph node element to represent */ element: GraphElement; /** Flag if the node accepts drop operations */ droppable?: boolean; /** Flag if the user is hovering on the node */ hover?: boolean; /** Flag if the current drag operation can be dropped on the node */ canDrop?: boolean; /** Flag if the user is dragging the node */ dragging?: boolean; /** Flag if the user is dragging an edge connected to the node */ edgeDragging?: boolean; /** Flag if the node is the current drop target */ dropTarget?: boolean; /** Flag indicating the node should be scaled, best on hover of the node at lowest scale level */ scaleNode?: boolean; /** Label for the node. Defaults to element.getLabel() */ label?: string; /** Secondary label for the node */ secondaryLabel?: string; /** Flag to show the label */ showLabel?: boolean; /** Additional classes to add to the label */ labelClassName?: string; /** Flag to scale the label, best on hover of the node at lowest scale level */ scaleLabel?: boolean; /** Position of the label, bottom or left. Defaults to element.getLabelPosition() or bottom */ labelPosition?: LabelPosition; /** The maximum length of the label before truncation */ truncateLength?: number; /** The label icon component to show in the label, takes precedence over labelIconClass */ labelIcon?: React.ReactNode; /** The Icon class to show in the label, ignored when labelIcon is specified */ labelIconClass?: string; /** Padding for the label's icon */ labelIconPadding?: number; /** Text for the label's badge */ badge?: string; /** Color to use for the label's badge background */ badgeColor?: string; /** Color to use for the label's badge text */ badgeTextColor?: string; /** Color to use for the label's badge border */ badgeBorderColor?: string; /** Additional classes to use for the label's badge */ badgeClassName?: string; /** Location of the badge relative to the label's text, inner or below */ badgeLocation?: BadgeLocation; /** Additional items to add to the node, typically decorators */ attachments?: React.ReactNode; /** Status of the node, Defaults to element.getNodeStatus() */ nodeStatus?: NodeStatus; /** Flag indicating whether the node's background color should indicate node status */ showStatusBackground?: boolean; /** Flag which displays the status decorator for the node */ showStatusDecorator?: boolean; /** Contents of a tooltip to show on the status decorator */ statusDecoratorTooltip?: React.ReactNode; /** Callback when the status decorator is clicked */ onStatusDecoratorClick?: (event: React.MouseEvent, element: GraphElement) => void; /** Function to return a custom shape component for the element */ getCustomShape?: (node: Node) => React.FunctionComponent; /** Function to return the center for a decorator for the quadrant */ getShapeDecoratorCenter?: (quadrant: TopologyQuadrant, node: Node) => { x: number; y: number }; /** Flag if the element selected. Part of WithSelectionProps */ selected?: boolean; /** Function to call when the element should become selected (or deselected). Part of WithSelectionProps */ onSelect?: OnSelect; /** A ref to add to the node for dragging. Part of WithDragNodeProps */ dragNodeRef?: WithDndDragProps['dndDragRef']; /** A ref to add to the node for drag and drop. Part of WithDndDragProps */ dndDragRef?: ConnectDragSource; /** A ref to add to the node for dropping. Part of WithDndDropProps */ dndDropRef?: ConnectDropTarget; /** Function to call for showing a connector creation indicator. Part of WithCreateConnectorProps */ onShowCreateConnector?: () => void; /** Function to call to hide the connector creation indicator. Part of WithCreateConnectorProps */ onHideCreateConnector?: () => void; /** Function to call to show a context menu for the node */ onContextMenu?: (e: React.MouseEvent) => void; /** Flag indicating that the context menu for the node is currently open */ contextMenuOpen?: boolean; /** Flag indicating the label should move to the top layer when the node is hovered, set to `false` if you are already using TOP_LAYER on hover */ raiseLabelOnHover?: boolean; // TODO: Update default to be false, assume demo code will be followed /** Hide context menu kebab for the node */ hideContextMenuKebab?: boolean; } const SCALE_UP_TIME = 200; type DefaultNodeInnerProps = Omit & { element: Node }; const DefaultNodeInner: React.FunctionComponent = observer( ({ className, element, selected, hover, scaleNode, showLabel = true, label, secondaryLabel, labelClassName, labelPosition, scaleLabel, truncateLength, labelIconClass, labelIcon, labelIconPadding, nodeStatus, showStatusBackground, showStatusDecorator = false, statusDecoratorTooltip, getCustomShape, getShapeDecoratorCenter, onStatusDecoratorClick, badge, badgeColor, badgeTextColor, badgeBorderColor, badgeClassName, badgeLocation, onSelect, children, attachments, dragNodeRef, dragging, edgeDragging, canDrop, dropTarget, dndDropRef, onHideCreateConnector, onShowCreateConnector, onContextMenu, contextMenuOpen, raiseLabelOnHover = true, hideContextMenuKebab }) => { const [nodeHovered, hoverRef] = useHover(); const [labelHovered, labelRef] = useHover(); const hovered = nodeHovered || labelHovered; const status = nodeStatus || element.getNodeStatus(); const refs = useCombineRefs(hoverRef, dragNodeRef); const { width, height } = element.getDimensions(); const isHover = hover !== undefined ? hover : hovered; const [nodeScale, setNodeScale] = useState(1); const decoratorRef = useRef(null); const statusDecorator = useMemo(() => { if (!status || !showStatusDecorator) { return null; } const icon = getStatusIcon(status); if (!icon) { return null; } const { x, y } = getShapeDecoratorCenter ? getShapeDecoratorCenter(StatusQuadrant, element) : getDefaultShapeDecoratorCenter(StatusQuadrant, element); const decorator = ( onStatusDecoratorClick(e, element)} icon={{icon}} ariaLabel={status} innerRef={decoratorRef} /> ); if (statusDecoratorTooltip) { return ( {decorator} ); } return decorator; }, [status, showStatusDecorator, getShapeDecoratorCenter, element, statusDecoratorTooltip, onStatusDecoratorClick]); useEffect(() => { if (isHover) { onShowCreateConnector && onShowCreateConnector(); } else { onHideCreateConnector && onHideCreateConnector(); } }, [isHover, onShowCreateConnector, onHideCreateConnector]); const ShapeComponent = (getCustomShape && getCustomShape(element)) || getShapeComponent(element); const groupClassName = css( styles.topologyNode, className, isHover && 'pf-m-hover', (dragging || edgeDragging) && 'pf-m-dragging', canDrop && 'pf-m-highlight', canDrop && dropTarget && 'pf-m-drop-target', selected && 'pf-m-selected', StatusModifier[status] ); const backgroundClassName = css( styles.topologyNodeBackground, showStatusBackground && StatusModifier[status], showStatusBackground && selected && 'pf-m-selected' ); let filter; if (status === 'danger') { filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_DANGER); } else if (isHover || dragging || edgeDragging || dropTarget) { filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_HOVER); } const nodeLabelPosition = labelPosition || element.getLabelPosition(); const scale = element.getGraph().getScale(); const animationRef = useRef(null); const scaleGoal = useRef(1); const nodeScaled = useRef(false); useEffect(() => { if (!scaleNode || scale >= 1) { setNodeScale(1); nodeScaled.current = false; if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = 0; } } else { scaleGoal.current = 1 / scale; const scaleDelta = scaleGoal.current - scale; const initTime = performance.now(); const bumpScale = (bumpTime: number) => { const scalePercent = (bumpTime - initTime) / SCALE_UP_TIME; const nextScale = Math.min(scale + scaleDelta * scalePercent, scaleGoal.current); setNodeScale(nextScale); if (nextScale < scaleGoal.current) { animationRef.current = requestAnimationFrame(bumpScale); } else { nodeScaled.current = true; animationRef.current = 0; } }; if (nodeScaled.current) { setNodeScale(scaleGoal.current); } else if (!animationRef.current) { animationRef.current = requestAnimationFrame(bumpScale); } } return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = 0; } }; }, [scale, scaleNode]); // counter scale label const counterScale = (scale: number, scaleMin: number, scaleMax: number, valueMin: number, valueMax: number) => { if (scale >= scaleMax) { return valueMin; } else if (scale <= scaleMin) { return valueMax; } return valueMin + (1 - (scale - scaleMin) / (scaleMax - scaleMin)) * (valueMax - valueMin); }; const labelScale = scaleLabel ? counterScale(scale, 0.35, 0.85, 1, 1.6) : 1; const labelPositionScale = scaleLabel ? Math.min(1, 1 / labelScale) : 1; const { translateX, translateY } = useMemo(() => { if (!scaleNode) { return { translateX: 0, translateY: 0 }; } const bounds = element.getBounds(); const translateX = bounds.width / 2 - (bounds.width / 2) * nodeScale; const translateY = bounds.height / 2 - (bounds.height / 2) * nodeScale; return { translateX, translateY }; }, [element, nodeScale, scaleNode]); const renderLabel = () => { if (!showLabel || !(label || element.getLabel())) { return null; } let labelX; let labelY; const labelPaddingX = 8; const labelPaddingY = 4; if (nodeLabelPosition === LabelPosition.right) { labelX = (width + labelPaddingX) * labelPositionScale; labelY = height / 2; } else if (nodeLabelPosition === LabelPosition.left) { labelX = 0; labelY = height / 2 - labelPaddingY; } else if (nodeLabelPosition === LabelPosition.top) { labelX = width / 2; labelY = labelPaddingY + labelPaddingY / 2; } else { labelX = (width / 2) * labelPositionScale; labelY = height + labelPaddingY + labelPaddingY / 2; } const nodeLabel = ( {label || element.getLabel()} ); if (isHover && raiseLabelOnHover) { return ( {nodeLabel} ); } return nodeLabel; }; return ( {ShapeComponent && ( )} {renderLabel()} {children} {statusDecorator} {attachments} ); } ); const DefaultNode: React.FunctionComponent = ({ element, showLabel = true, showStatusDecorator = false, ...rest }: DefaultNodeProps) => { if (!isNode(element)) { throw new Error('DefaultNode must be used only on Node elements'); } return ( ); }; export default DefaultNode;