From e41b72a67f466ffdb484b57b47ed1cad5bf99201 Mon Sep 17 00:00:00 2001 From: Jenny <32821331+jenny-s51@users.noreply.github.com> Date: Mon, 24 Jun 2024 14:12:48 -0400 Subject: [PATCH] feat(pipelines): add support for custom GroupLabelComponent (#221) fix(pipelines): add correct location, scaling, and anchors for expanded task group's pill labels. (#10) fix(pipelines): add status prop to group labels update import statement --- .../pipelineGroupsDemo/DemoTaskGroup.tsx | 4 +- .../pipelinesDemo/DemoPipelinesGroup.tsx | 5 +- .../src/components/nodes/labels/NodeLabel.tsx | 5 +- .../src/components/nodes/labels/index.ts | 1 + .../components/groups/DefaultTaskGroup.tsx | 10 +- .../groups/DefaultTaskGroupExpanded.tsx | 66 +-- .../components/groups/TaskGroupPillLabel.tsx | 110 +++++ .../src/pipelines/components/groups/index.ts | 1 + .../pipelines/components/nodes/TaskNode.tsx | 418 +--------------- .../pipelines/components/nodes/TaskPill.tsx | 459 ++++++++++++++++++ .../src/pipelines/components/nodes/index.ts | 1 + 11 files changed, 642 insertions(+), 438 deletions(-) create mode 100644 packages/module/src/pipelines/components/groups/TaskGroupPillLabel.tsx create mode 100644 packages/module/src/pipelines/components/nodes/TaskPill.tsx diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx index f7c5100e..f07457e3 100644 --- a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx @@ -12,7 +12,8 @@ import { EdgeCreationTypes, useHover, ScaleDetailsLevel, - RunStatus + RunStatus, + TaskGroupPillLabel } from '@patternfly/react-topology'; import { DEFAULT_TASK_HEIGHT, GROUP_TASK_WIDTH } from './createDemoPipelineGroupsNodes'; @@ -42,6 +43,7 @@ const DemoTaskGroup: React.FunctionComponent = ({ element, . collapsible collapsedWidth={GROUP_TASK_WIDTH} collapsedHeight={DEFAULT_TASK_HEIGHT} + GroupLabelComponent={TaskGroupPillLabel} element={element as Node} centerLabelOnEdge recreateLayoutOnCollapseChange diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx index 8a2e48ea..da313b93 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/DemoPipelinesGroup.tsx @@ -4,7 +4,6 @@ import { GraphElement, LabelPosition, observer, - ScaleDetailsLevel, WithContextMenuProps, WithDragNodeProps, WithSelectionProps @@ -18,14 +17,14 @@ type DemoPipelinesGroupProps = { const DemoPipelinesGroup: React.FunctionComponent = ({ element }) => { const data = element.getData(); - const detailsLevel = element.getGraph().getDetailsLevel(); return ( ); diff --git a/packages/module/src/components/nodes/labels/NodeLabel.tsx b/packages/module/src/components/nodes/labels/NodeLabel.tsx index c464e686..0f5d0ca7 100644 --- a/packages/module/src/components/nodes/labels/NodeLabel.tsx +++ b/packages/module/src/components/nodes/labels/NodeLabel.tsx @@ -9,9 +9,10 @@ import LabelBadge from './LabelBadge'; import LabelContextMenu from './LabelContextMenu'; import LabelIcon from './LabelIcon'; import LabelActionIcon from './LabelActionIcon'; -import { BadgeLocation, LabelPosition, NodeStatus } from '../../../types'; +import { BadgeLocation, LabelPosition, Node, NodeStatus } from '../../../types'; -type NodeLabelProps = { +export type NodeLabelProps = { + element?: Node; children?: string; className?: string; paddingX?: number; diff --git a/packages/module/src/components/nodes/labels/index.ts b/packages/module/src/components/nodes/labels/index.ts index 11dcc59c..43a9736d 100644 --- a/packages/module/src/components/nodes/labels/index.ts +++ b/packages/module/src/components/nodes/labels/index.ts @@ -3,3 +3,4 @@ export { default as LabelBadge } from './LabelBadge'; export { default as LabelContextMenu } from './LabelContextMenu'; export { default as LabelIcon } from './LabelIcon'; export { default as NodeLabel } from './NodeLabel'; +export type { NodeLabelProps } from './NodeLabel'; diff --git a/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx b/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx index 24043627..4a5b8cb6 100644 --- a/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx +++ b/packages/module/src/pipelines/components/groups/DefaultTaskGroup.tsx @@ -10,6 +10,7 @@ import DefaultTaskGroupCollapsed from './DefaultTaskGroupCollapsed'; import DefaultTaskGroupExpanded from './DefaultTaskGroupExpanded'; import { RunStatus } from '../../types'; import { DEFAULT_SPACER_NODE_TYPE } from '../../const'; +import { TaskGroupPillLabelProps } from './TaskGroupPillLabel'; export interface EdgeCreationTypes { spacerNodeType?: string; @@ -62,6 +63,8 @@ export interface DefaultTaskGroupProps { truncateLength?: number; /** Space between the label and the group. Defaults to 17 */ labelOffset?: number; + /** Label to show for the group, Defaults to NodeLabel, only applicable to expanded groups */ + GroupLabelComponent?: React.FC; /** Center the label on the edge, overrides the label offset, only applicable to expanded groups */ centerLabelOnEdge?: boolean; /** The Icon class to show in the label, ignored when labelIcon is specified */ @@ -126,7 +129,6 @@ type PipelinesDefaultGroupInnerProps = Omit & const DefaultTaskGroupInner: React.FunctionComponent = observer( ({ - className, element, badge, onCollapseChange, @@ -207,7 +209,6 @@ const DefaultTaskGroupInner: React.FunctionComponent ); } - return ( - // TODO: Support status indicators on expanded state. - - ); + return ; } ); diff --git a/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx b/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx index 9d1c48eb..0628ba46 100644 --- a/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx +++ b/packages/module/src/pipelines/components/groups/DefaultTaskGroupExpanded.tsx @@ -6,8 +6,8 @@ import CollapseIcon from '@patternfly/react-icons/dist/esm/icons/compress-alt-ic import NodeLabel from '../../../components/nodes/labels/NodeLabel'; import { Layer } from '../../../components/layers'; import { GROUPS_LAYER, TOP_LAYER } from '../../../const'; -import { maxPadding, useCombineRefs, useHover, useSize } from '../../../utils'; -import { AnchorEnd, isGraph, LabelPosition, Node, NodeStyle, ScaleDetailsLevel } from '../../../types'; +import { useCombineRefs, useHover, useSize } from '../../../utils'; +import { AnchorEnd, isGraph, LabelPosition, Node, ScaleDetailsLevel } from '../../../types'; import { useAnchor, useDragNode } from '../../../behavior'; import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; import TaskGroupSourceAnchor from '../anchors/TaskGroupSourceAnchor'; @@ -28,6 +28,8 @@ const DefaultTaskGroupExpanded: React.FunctionComponent(hoverRef, dragNodeRef); const isHover = hover !== undefined ? hover : hovered || labelHover; + const [labelSize, labelRef] = useSize([centerLabelOnEdge]); const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; const groupLabelPosition = labelPosition ?? element.getLabelPosition() ?? LabelPosition.bottom; let parent = element.getParent(); const detailsLevel = element.getGraph().getDetailsLevel(); + let altGroup = false; while (!isGraph(parent)) { altGroup = !altGroup; @@ -105,23 +108,7 @@ const DefaultTaskGroupExpanded: React.FunctionComponent c.isVisible()); - - // cast to number and coerce - const padding = maxPadding(element.getStyle().padding ?? 17); - - const { minX, minY, maxX, maxY } = children.reduce( - (acc, child) => { - const bounds = child.getBounds(); - return { - minX: Math.min(acc.minX, bounds.x - padding), - minY: Math.min(acc.minY, bounds.y - padding), - maxX: Math.max(acc.maxX, bounds.x + bounds.width + padding), - maxY: Math.max(acc.maxY, bounds.y + bounds.height + padding) - }; - }, - { minX: Infinity, minY: Infinity, maxX: 0, maxY: 0 } - ); + const bounds = element.getBounds(); const [labelX, labelY] = React.useMemo(() => { if (!showLabel || !(label || element.getLabel())) { @@ -129,17 +116,29 @@ const DefaultTaskGroupExpanded: React.FunctionComponent c.isVisible()); if (children.length === 0) { return null; } @@ -170,17 +169,20 @@ const DefaultTaskGroupExpanded: React.FunctionComponent - : undefined} onActionIconClick={() => onCollapseChange(element, true)} > {label || element.getLabel()} - + ) : null; @@ -208,10 +210,10 @@ const DefaultTaskGroupExpanded: React.FunctionComponent diff --git a/packages/module/src/pipelines/components/groups/TaskGroupPillLabel.tsx b/packages/module/src/pipelines/components/groups/TaskGroupPillLabel.tsx new file mode 100644 index 00000000..b4558565 --- /dev/null +++ b/packages/module/src/pipelines/components/groups/TaskGroupPillLabel.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import styles from '../../../css/topology-components'; +import TaskPill, { TaskPillProps } from '../nodes/TaskPill'; +import { NodeLabelProps } from '../../../components'; +import { RunStatus } from '../../types'; +import useCombineRefs from '../../../utils/useCombineRefs'; +import { useSize } from '../../../utils'; +import { LabelPosition, ScaleDetailsLevel } from '../../../types'; + +export type TaskGroupPillLabelProps = { + shadowCount?: number; + runStatus?: RunStatus; + labelOffset?: number; +} & NodeLabelProps & + Omit; + +const TaskGroupPillLabel: React.FC = ({ + element, + labelOffset = 17, + badge, + badgeColor, + badgeTextColor, + badgeBorderColor, + badgeClassName, + runStatus, + truncateLength, + boxRef, + position, + centerLabelOnEdge, + onContextMenu, + contextMenuOpen, + actionIcon, + onActionIconClick, + ...rest +}) => { + const [labelSize, labelRef] = useSize([]); + const pillRef = useCombineRefs(boxRef, labelRef); + const labelWidth = labelSize?.width || 0; + const labelHeight = labelSize?.height || 0; + + const bounds = element.getBounds(); + + const detailsLevel = element.getGraph().getDetailsLevel(); + const scale = element.getGraph().getScale(); + const medScale = element.getGraph().getDetailsLevelThresholds().medium; + const labelScale = detailsLevel !== ScaleDetailsLevel.high ? Math.min(1 / scale, 1 / medScale) : 1; + const labelPositionScale = detailsLevel !== ScaleDetailsLevel.high ? 1 / labelScale : 1; + + const { startX, startY } = React.useMemo(() => { + let startX: number; + let startY: number; + const scaledWidth = labelWidth / labelPositionScale; + const scaledHeight = labelHeight / labelPositionScale; + + if (position === LabelPosition.top) { + startX = bounds.x + bounds.width / 2 - scaledWidth / 2; + startY = bounds.y - (centerLabelOnEdge ? scaledHeight / 2 : labelOffset); + } else if (position === LabelPosition.right) { + startX = bounds.x + bounds.width + (centerLabelOnEdge ? -scaledWidth / 2 : labelOffset); + startY = bounds.y + bounds.height / 2; + } else if (position === LabelPosition.left) { + startX = bounds.x - (centerLabelOnEdge ? scaledWidth / 2 : scaledWidth + labelOffset); + startY = bounds.y + bounds.height / 2; + } else { + startX = bounds.x + bounds.width / 2 - scaledWidth / 2; + startY = bounds.y + bounds.height + (centerLabelOnEdge ? -scaledHeight / 2 : labelOffset); + } + return { startX, startY }; + }, [ + labelPositionScale, + position, + bounds.width, + bounds.x, + bounds.y, + bounds.height, + centerLabelOnEdge, + labelHeight, + labelOffset, + labelWidth + ]); + + return ( + + ); +}; + +export default observer(TaskGroupPillLabel); diff --git a/packages/module/src/pipelines/components/groups/index.ts b/packages/module/src/pipelines/components/groups/index.ts index a58f87d3..89a22d9e 100644 --- a/packages/module/src/pipelines/components/groups/index.ts +++ b/packages/module/src/pipelines/components/groups/index.ts @@ -2,3 +2,4 @@ export type { EdgeCreationTypes } from './DefaultTaskGroup'; export { default as DefaultTaskGroup } from './DefaultTaskGroup'; export { default as DefaultTaskGroupExpanded } from './DefaultTaskGroupExpanded'; export { default as DefaultTaskGroupCollapsed } from './DefaultTaskGroupCollapsed'; +export { default as TaskGroupPillLabel } from './TaskGroupPillLabel'; diff --git a/packages/module/src/pipelines/components/nodes/TaskNode.tsx b/packages/module/src/pipelines/components/nodes/TaskNode.tsx index 1f484268..527f4005 100644 --- a/packages/module/src/pipelines/components/nodes/TaskNode.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskNode.tsx @@ -3,27 +3,16 @@ import { action } from 'mobx'; import { TooltipProps } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-pipelines'; -import topologyStyles from '../../../css/topology-components'; -import { Popover, PopoverProps, Tooltip } from '@patternfly/react-core'; +import { PopoverProps, Tooltip } from '@patternfly/react-core'; import { observer } from '../../../mobx-exports'; import { AnchorEnd, GraphElement, isNode, Node, ScaleDetailsLevel } from '../../../types'; import { RunStatus } from '../../types'; import { OnSelect, useAnchor } from '../../../behavior'; -import { truncateMiddle } from '../../../utils/truncate-middle'; -import { createSvgIdUrl, getNodeScaleTranslation, useCombineRefs, useHover, useSize } from '../../../utils'; -import { getRunStatusModifier, nonShadowModifiers } from '../../utils'; -import StatusIcon from '../../utils/StatusIcon'; +import { getNodeScaleTranslation, useHover, useSize } from '../../../utils'; import { TaskNodeSourceAnchor, TaskNodeTargetAnchor } from '../anchors'; -import LabelActionIcon from '../../../components/nodes/labels/LabelActionIcon'; -import LabelContextMenu from '../../../components/nodes/labels/LabelContextMenu'; -import NodeShadows, { - NODE_SHADOW_FILTER_ID_DANGER, - NODE_SHADOW_FILTER_ID_HOVER -} from '../../../components/nodes/NodeShadows'; -import LabelBadge from '../../../components/nodes/labels/LabelBadge'; -import LabelIcon from '../../../components/nodes/labels/LabelIcon'; import { useScaleNode } from '../../../hooks'; import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; +import TaskPill from './TaskPill'; const STATUS_ICON_SIZE = 16; const SCALE_UP_TIME = 200; @@ -123,63 +112,24 @@ const TaskNodeInner: React.FC = observer( ({ element, className, - paddingX = 8, - paddingY = 8, - status, statusIconSize = STATUS_ICON_SIZE, - showStatusState = true, scaleNode, - hideDetailsAtMedium, - hiddenDetailsShownStatuses = [RunStatus.Failed, RunStatus.FailedToStart, RunStatus.Cancelled], - leadIcon, - badge, - badgeColor, - badgeTextColor, - badgeBorderColor, - badgeClassName = styles.topologyPipelinesPillBadge, - badgeTooltip, badgePopoverProps, - badgePopoverParams, - customStatusIcon, - nameLabelClass, - taskIconClass, - taskIcon, - taskIconTooltip, - taskIconPadding = 4, - hover, - truncateLength = 14, toolTip, toolTipProps, disableTooltip = false, - selected, - onSelect, hasWhenExpression = false, whenSize = 0, whenOffset = 0, - onContextMenu, - contextMenuOpen, - actionIcon, - actionIconClassName, - onActionIconClick, - shadowCount = 0, - shadowOffset = 8, - children + ...rest }) => { const [hovered, hoverRef] = useHover(); + // const isHover = hover !== undefined ? hover : hovered; const taskRef = React.useRef(); - const taskIconComponentRef = React.useRef(); - const isHover = hover !== undefined ? hover : hovered; - const { width, height: boundsHeight } = element.getBounds(); - const label = truncateMiddle(element.getLabel(), { length: truncateLength, omission: '...' }); - const [textSize, textRef] = useSize([label, className]); - const nameLabelTriggerRef = React.useRef(); - const nameLabelRef = useCombineRefs(textRef, nameLabelTriggerRef); - const [statusSize, statusRef] = useSize([status, showStatusState, statusIconSize]); - const [leadSize, leadIconRef] = useSize([leadIcon]); - const [badgeSize, badgeRef] = useSize([badge]); - const badgeLabelTriggerRef = React.useRef(); - const [actionSize, actionRef] = useSize([actionIcon, paddingX]); - const [contextSize, contextRef] = useSize([onContextMenu, paddingX]); + const [pillSize, pillRef] = useSize(); + const pillWidth = pillSize?.width || 0; + const { width } = element.getBounds(); + const detailsLevel = element.getGraph().getDetailsLevel(); const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; @@ -194,8 +144,6 @@ const TaskNodeInner: React.FC = observer( ); } - const textWidth = textSize?.width ?? 0; - const textHeight = textSize?.height ?? 0; useAnchor( React.useCallback( (node: Node) => new TaskNodeSourceAnchor(node, detailsLevel, statusIconSize + 4, verticalLayout), @@ -216,100 +164,6 @@ const TaskNodeInner: React.FC = observer( AnchorEnd.target ); - const { - height, - statusStartX, - textStartX, - actionStartX, - contextStartX, - pillWidth, - badgeStartX, - iconWidth, - iconStartX, - leadIconStartX, - offsetX - } = React.useMemo(() => { - if (!textSize) { - return { - height: 0, - statusStartX: 0, - textStartX: 0, - actionStartX: 0, - contextStartX: 0, - pillWidth: 0, - badgeStartX: 0, - iconWidth: 0, - iconStartX: 0, - leadIconStartX: 0, - offsetX: 0 - }; - } - const height: number = textHeight + 2 * paddingY; - const startX = paddingX + paddingX / 2; - - const iconWidth = taskIconClass || taskIcon ? height - taskIconPadding : 0; - const iconStartX = -(iconWidth * 0.75); - - const statusStartX = startX - statusIconSize / 4; // Adjust for icon padding - const statusSpace = status && showStatusState && statusSize ? statusSize.width + paddingX : 0; - - const leadIconStartX = startX + statusSpace; - const leadIconSpace = leadIcon ? leadSize.width + paddingX : 0; - - const textStartX = leadIconStartX + leadIconSpace; - const textSpace = textWidth + paddingX; - - const badgeStartX = textStartX + textSpace; - const badgeSpace = badge && badgeSize ? badgeSize.width + paddingX : 0; - - const actionStartX = badgeStartX + badgeSpace; - const actionSpace = actionIcon && actionSize ? actionSize.width + paddingX : 0; - - const contextStartX = actionStartX + actionSpace; - const contextSpace = onContextMenu && contextSize ? contextSize.width + paddingX / 2 : 0; - - const pillWidth = contextStartX + contextSpace + paddingX / 2; - - const offsetX = verticalLayout ? (width - pillWidth) / 2 : 0; - - return { - height, - statusStartX, - textStartX, - actionStartX, - contextStartX, - badgeStartX, - iconWidth, - iconStartX, - leadIconStartX, - pillWidth, - offsetX - }; - }, [ - textSize, - textHeight, - textWidth, - paddingY, - paddingX, - taskIconClass, - taskIcon, - taskIconPadding, - statusIconSize, - status, - showStatusState, - leadSize, - leadIcon, - statusSize, - badgeSize, - badge, - actionIcon, - actionSize, - onContextMenu, - contextSize, - verticalLayout, - width - ]); - React.useEffect(() => { const sourceEdges = element.getSourceEdges(); action(() => { @@ -336,245 +190,21 @@ const TaskNodeInner: React.FC = observer( const nodeScale = useScaleNode(scaleNode, scale, SCALE_UP_TIME); const { translateX, translateY } = getNodeScaleTranslation(element, nodeScale, scaleNode); - const nameLabel = ( - - {label} - - ); - - const runStatusModifier = getRunStatusModifier(status); - const pillClasses = css( - styles.topologyPipelinesPill, - className, - isHover && styles.modifiers.hover, - runStatusModifier, - selected && styles.modifiers.selected, - onSelect && styles.modifiers.selectable - ); - - let filter: string; - if (runStatusModifier === styles.modifiers.danger) { - filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_DANGER); - } else if (isHover && !nonShadowModifiers.includes(runStatusModifier)) { - filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_HOVER); - } - - const taskIconComponent = (taskIconClass || taskIcon) && ( - ); - - const badgeLabel = badge ? ( - - ) : null; - - let badgeComponent: React.ReactNode; - if (badgeLabel && badgeTooltip) { - badgeComponent = ( - - {badgeLabel} - - ); - } else if (badgeLabel && badgePopoverParams) { - badgeComponent = ( - e.stopPropagation()}> - - {badgeLabel} - - - ); - } else { - badgeComponent = badgeLabel; - } - - const renderTask = () => { - if (showStatusState && !scaleNode && hideDetailsAtMedium && detailsLevel !== ScaleDetailsLevel.high) { - const statusBackgroundRadius = statusIconSize / 2 + 4; - const upScale = 1 / scale; - - const translateX = verticalLayout ? width / 2 - statusBackgroundRadius * upScale : 0; - const translateY = verticalLayout ? 0 : (boundsHeight - statusBackgroundRadius * 2 * upScale) / 2; - return ( - - - {status && (!hiddenDetailsShownStatuses || hiddenDetailsShownStatuses.includes(status)) ? ( - - - {customStatusIcon ?? } - - - ) : null} - - ); - } - - const shadows = []; - for (let i = shadowCount; i > 0; i--) { - shadows.push( - - ); - } - return ( - - - {shadows} - - - {element.getLabel() !== label && !disableTooltip ? ( - - {nameLabel} - - ) : ( - nameLabel - )} - - {status && showStatusState && ( - - - {customStatusIcon ?? } - - - )} - {leadIcon && ( - - {leadIcon} - - )} - {taskIconComponent && - (taskIconTooltip ? ( - - {taskIconComponent} - - ) : ( - taskIconComponent - ))} - {badgeComponent} - {actionIcon && ( - <> - - - - )} - {textSize && onContextMenu && ( - <> - - - - )} - {children} - - ); - }; - return ( = observer( ref={hoverRef} > {!toolTip || disableTooltip ? ( - renderTask() + taskPill ) : ( = observer( {...(toolTipProps ?? {})} content={toolTip} > - {renderTask()} + {taskPill} )} diff --git a/packages/module/src/pipelines/components/nodes/TaskPill.tsx b/packages/module/src/pipelines/components/nodes/TaskPill.tsx new file mode 100644 index 00000000..49d9f108 --- /dev/null +++ b/packages/module/src/pipelines/components/nodes/TaskPill.tsx @@ -0,0 +1,459 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '../../../css/topology-pipelines'; +import topologyStyles from '../../../css/topology-components'; +import { Popover, Tooltip } from '@patternfly/react-core'; +import { observer } from '../../../mobx-exports'; +import { Node, ScaleDetailsLevel } from '../../../types'; +import { RunStatus } from '../../types'; +import { truncateMiddle } from '../../../utils/truncate-middle'; +import { createSvgIdUrl, useCombineRefs, useHover, useSize } from '../../../utils'; +import { getRunStatusModifier, nonShadowModifiers } from '../../utils'; +import StatusIcon from '../../utils/StatusIcon'; +import LabelActionIcon from '../../../components/nodes/labels/LabelActionIcon'; +import LabelContextMenu from '../../../components/nodes/labels/LabelContextMenu'; +import NodeShadows, { + NODE_SHADOW_FILTER_ID_DANGER, + NODE_SHADOW_FILTER_ID_HOVER +} from '../../../components/nodes/NodeShadows'; +import LabelBadge from '../../../components/nodes/labels/LabelBadge'; +import LabelIcon from '../../../components/nodes/labels/LabelIcon'; +import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; +import { TaskNodeProps } from './TaskNode'; + +const STATUS_ICON_SIZE = 16; + +export interface TaskPillProps extends Omit { + verticalLayout?: boolean; + width?: number; + x: number; + y: number; + taskRef?: React.Ref; + pillRef: (node: SVGGraphicsElement) => void; + element: Node; +} + +const TaskPill: React.FC = observer( + ({ + element, + taskRef, + pillRef, + className, + width = 0, + paddingX = 8, + paddingY = 8, + status, + statusIconSize = STATUS_ICON_SIZE, + showStatusState = true, + scaleNode, + hideDetailsAtMedium, + hiddenDetailsShownStatuses = [RunStatus.Failed, RunStatus.FailedToStart, RunStatus.Cancelled], + leadIcon, + badge, + badgeColor, + badgeTextColor, + badgeBorderColor, + badgeClassName = styles.topologyPipelinesPillBadge, + badgeTooltip, + badgePopoverProps, + badgePopoverParams, + nameLabelClass, + taskIconClass, + taskIcon, + taskIconTooltip, + taskIconPadding = 4, + hover, + truncateLength = 14, + disableTooltip = false, + selected, + onSelect, + hasWhenExpression = false, + onContextMenu, + contextMenuOpen, + actionIcon, + actionIconClassName, + onActionIconClick, + shadowCount = 0, + shadowOffset = 8, + children, + x, + y + }) => { + const [hovered] = useHover(); + const taskIconComponentRef = React.useRef(); + const isHover = hover !== undefined ? hover : hovered; + const label = truncateMiddle(element.getLabel(), { length: truncateLength, omission: '...' }); + const [textSize, textRef] = useSize([label, className]); + const nameLabelTriggerRef = React.useRef(); + const nameLabelRef = useCombineRefs(textRef, nameLabelTriggerRef); + const [statusSize, statusRef] = useSize([status, showStatusState, statusIconSize]); + const [leadSize, leadIconRef] = useSize([leadIcon]); + const [badgeSize, badgeRef] = useSize([badge]); + const badgeLabelTriggerRef = React.useRef(); + const [actionSize, actionRef] = useSize([actionIcon, paddingX]); + const [contextSize, contextRef] = useSize([onContextMenu, paddingX]); + const detailsLevel = element.getGraph().getDetailsLevel(); + const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; + + if (badgePopoverProps) { + // eslint-disable-next-line no-console + console.warn('badgePopoverProps is deprecated. Use badgePopoverParams instead.'); + } + if (hasWhenExpression) { + // eslint-disable-next-line no-console + console.warn( + 'hasWhenExpression is deprecated. Set whenSize and whenOffset only when showing the when expression.' + ); + } + + const textWidth = textSize?.width ?? 0; + const textHeight = textSize?.height ?? 0; + + const { + height, + statusStartX, + textStartX, + actionStartX, + contextStartX, + pillWidth, + badgeStartX, + iconWidth, + iconStartX, + leadIconStartX, + offsetX + } = React.useMemo(() => { + if (!textSize) { + return { + height: 0, + statusStartX: 0, + textStartX: 0, + actionStartX: 0, + contextStartX: 0, + pillWidth: 0, + badgeStartX: 0, + iconWidth: 0, + iconStartX: 0, + leadIconStartX: 0, + offsetX: 0 + }; + } + const height: number = textHeight + 2 * paddingY; + const startX = paddingX + paddingX / 2; + + const iconWidth = taskIconClass || taskIcon ? height - taskIconPadding : 0; + const iconStartX = -(iconWidth * 0.75); + + const statusStartX = startX - statusIconSize / 4; // Adjust for icon padding + const statusSpace = status && showStatusState && statusSize ? statusSize.width + paddingX : 0; + + const leadIconStartX = startX + statusSpace; + const leadIconSpace = leadIcon ? leadSize.width + paddingX : 0; + + const textStartX = leadIconStartX + leadIconSpace; + const textSpace = textWidth + paddingX; + + const badgeStartX = textStartX + textSpace; + const badgeSpace = badge && badgeSize ? badgeSize.width + paddingX : 0; + + const actionStartX = badgeStartX + badgeSpace; + const actionSpace = actionIcon && actionSize ? actionSize.width + paddingX : 0; + + const contextStartX = actionStartX + actionSpace; + const contextSpace = onContextMenu && contextSize ? contextSize.width + paddingX / 2 : 0; + + const pillWidth = contextStartX + contextSpace + paddingX / 2; + + const offsetX = verticalLayout ? (width - pillWidth) / 2 : 0; + + return { + height, + statusStartX, + textStartX, + actionStartX, + contextStartX, + badgeStartX, + iconWidth, + iconStartX, + leadIconStartX, + pillWidth, + offsetX + }; + }, [ + textSize, + textHeight, + textWidth, + paddingY, + paddingX, + taskIconClass, + taskIcon, + taskIconPadding, + statusIconSize, + status, + showStatusState, + leadSize, + leadIcon, + statusSize, + badgeSize, + badge, + actionIcon, + actionSize, + onContextMenu, + contextSize, + verticalLayout, + width + ]); + + const scale = element.getGraph().getScale(); + + const nameLabel = ( + + {label} + + ); + + const runStatusModifier = getRunStatusModifier(status); + const pillClasses = css( + styles.topologyPipelinesPill, + className, + isHover && styles.modifiers.hover, + runStatusModifier, + selected && styles.modifiers.selected, + onSelect && styles.modifiers.selectable + ); + + // Force an update of the given pillRef when dependencies change + const pillUpdatedRef = React.useCallback( + (node: SVGGraphicsElement): void => { + pillRef(node); + }, + // dependencies causing the pill rect to resize + // eslint-disable-next-line react-hooks/exhaustive-deps + [pillClasses, width, height] + ); + + let filter: string; + if (runStatusModifier === styles.modifiers.danger) { + filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_DANGER); + } else if (isHover && !nonShadowModifiers.includes(runStatusModifier)) { + filter = createSvgIdUrl(NODE_SHADOW_FILTER_ID_HOVER); + } + + const taskIconComponent = (taskIconClass || taskIcon) && ( + + ); + + const badgeLabel = badge ? ( + + ) : null; + + let badgeComponent: React.ReactNode; + if (badgeLabel && badgeTooltip) { + badgeComponent = ( + + {badgeLabel} + + ); + } else if (badgeLabel && badgePopoverParams) { + badgeComponent = ( + e.stopPropagation()}> + + {badgeLabel} + + + ); + } else { + badgeComponent = badgeLabel; + } + + if (showStatusState && !scaleNode && hideDetailsAtMedium && detailsLevel !== ScaleDetailsLevel.high) { + const statusBackgroundRadius = statusIconSize / 2 + 4; + const upScale = 1 / scale; + const { height: boundsHeight } = element.getBounds(); + + const translateX = verticalLayout ? width / 2 - statusBackgroundRadius * upScale : 0; + const translateY = verticalLayout ? 0 : (boundsHeight - statusBackgroundRadius * 2 * upScale) / 2; + return ( + + + {status && (!hiddenDetailsShownStatuses || hiddenDetailsShownStatuses.includes(status)) ? ( + + + + + + ) : null} + + ); + } + + const shadows = []; + for (let i = shadowCount; i > 0; i--) { + shadows.push( + + ); + } + return ( + + + {shadows} + + + {element.getLabel() !== label && !disableTooltip ? ( + + {nameLabel} + + ) : ( + nameLabel + )} + + {status && showStatusState && ( + + + + + + )} + {leadIcon && ( + + {leadIcon} + + )} + {taskIconComponent && + (taskIconTooltip ? ( + + {taskIconComponent} + + ) : ( + taskIconComponent + ))} + {badgeComponent} + {actionIcon && ( + <> + + + + )} + {textSize && onContextMenu && ( + <> + + + + )} + {children} + + ); + } +); + +export default TaskPill; diff --git a/packages/module/src/pipelines/components/nodes/index.ts b/packages/module/src/pipelines/components/nodes/index.ts index 3fdcc26c..4d2496bf 100644 --- a/packages/module/src/pipelines/components/nodes/index.ts +++ b/packages/module/src/pipelines/components/nodes/index.ts @@ -2,4 +2,5 @@ export { default as FinallyNode } from './FinallyNode'; export { default as SpacerNode } from './SpacerNode'; export { default as StatusIcon } from '../../utils/StatusIcon'; export { default as TaskNode } from './TaskNode'; +export { default as TaskPill } from './TaskPill'; export { default as WhenNode } from '../../decorators/WhenDecorator';