diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx index 09cc7430..28806320 100644 --- a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx @@ -3,37 +3,57 @@ import { observer } from 'mobx-react'; import { AnchorEnd, DagreLayoutOptions, - DefaultGroup, + PipelinesDefaultGroup, GraphElement, isNode, LabelPosition, Node, TOP_TO_BOTTOM, useAnchor, + WithContextMenuProps, + WithSelectionProps, + ShapeProps, + WithDragNodeProps, } from '@patternfly/react-topology'; import TaskGroupSourceAnchor from './TaskGroupSourceAnchor'; import TaskGroupTargetAnchor from './TaskGroupTargetAnchor'; -interface DemoTaskNodeProps { +type DemoTaskGroupProps = { element: GraphElement; -} + collapsible?: boolean; + collapsedWidth?: number; + collapsedHeight?: number; + onCollapseChange?: (group: Node, collapsed: boolean) => void; + getCollapsedShape?: (node: Node) => React.FunctionComponent; + collapsedShadowOffset?: number; // defaults to 10 +} & WithContextMenuProps & + WithDragNodeProps & + WithSelectionProps; -const DemoTaskGroup: React.FunctionComponent = ({ element, ...rest }) => { +const DemoTaskGroup: React.FunctionComponent = ({ element, collapsedWidth, collapsedHeight, ...rest }) => { const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; useAnchor( - React.useCallback((node: Node) =>new TaskGroupSourceAnchor(node, verticalLayout), [verticalLayout]), + React.useCallback((node: Node) => new TaskGroupSourceAnchor(node, verticalLayout), [verticalLayout]), AnchorEnd.source ); useAnchor( - React.useCallback((node: Node) => new TaskGroupTargetAnchor(node, verticalLayout),[verticalLayout]), + React.useCallback((node: Node) => new TaskGroupTargetAnchor(node, verticalLayout), [verticalLayout]), AnchorEnd.target ); if (!isNode(element)) { return null; } return ( - + ); }; diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts index 383c99cd..9f66851b 100644 --- a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts @@ -7,7 +7,7 @@ import { export const NODE_PADDING_VERTICAL = 15; export const NODE_PADDING_HORIZONTAL = 15; -export const GROUP_PADDING_VERTICAL = 15; +export const GROUP_PADDING_VERTICAL = 50; export const GROUP_PADDING_HORIZONTAL = 25; export const DEFAULT_TASK_WIDTH = 180; diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx index edc7b214..a4882a2e 100644 --- a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx @@ -22,7 +22,7 @@ const pipelineGroupsComponentFactory: ComponentFactory = ( } switch (type) { case 'Execution': - return DemoTaskGroup; + return withSelection()(DemoTaskGroup); case 'Task': return withSelection()(DemoTaskNode); case DEFAULT_SPACER_NODE_TYPE: diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx index a33ef892..446bcd25 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/pipelineComponentFactory.tsx @@ -20,6 +20,7 @@ import { import DemoTaskNode from './DemoTaskNode'; import DemoFinallyNode from './DemoFinallyNode'; import DemoTaskGroupEdge from './DemoTaskGroupEdge'; +import StylePipelinesGroup from '../stylesDemo/StylePipelinesGroup'; export const GROUPED_EDGE_TYPE = 'GROUPED_EDGE'; @@ -52,7 +53,7 @@ const pipelineComponentFactory: ComponentFactory = ( case DEFAULT_FINALLY_NODE_TYPE: return withContextMenu(() => defaultMenu)(withSelection()(DemoFinallyNode)); case 'task-group': - return DefaultTaskGroup; + return withSelection()(StylePipelinesGroup); case 'finally-group': return DefaultTaskGroup; case DEFAULT_SPACER_NODE_TYPE: diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx index ef18f00a..c2ec480f 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/useDemoPipelineNodes.tsx @@ -4,9 +4,10 @@ import { DEFAULT_TASK_NODE_TYPE, DEFAULT_WHEN_OFFSET, DEFAULT_WHEN_SIZE, + LabelPosition, PipelineNodeModel, RunStatus, - WhenStatus, + WhenStatus } from '@patternfly/react-topology'; export const NODE_PADDING_VERTICAL = 45; @@ -149,7 +150,14 @@ export const useDemoPipelineNodes = ( type: 'task-group', children: parallelTasks.map(t => t.id), group: true, - label: 'Parallel tasks' + label: 'Parallel tasks', + data: { + badge: 'Label', + collapsedWidth: 75, + collapsedHeight: 42, + collapsible: true, + labelPosition: LabelPosition.top + } }); } } @@ -190,7 +198,13 @@ export const useDemoPipelineNodes = ( type: 'task-group', children: [], group: true, - label: `Group ${task.data.columnGroup}` + label: `Group ${task.data.columnGroup}`, + data: { + collapsedWidth: 75, + collapsedHeight: 75, + collapsible: true, + labelPosition: LabelPosition.top + } }; acc.push(taskGroup); } @@ -221,8 +235,8 @@ export const useDemoPipelineNodes = ( taskProgress: '3/4', taskType: 'java', taskTopic: 'Environment', - columnGroup: TASK_STATUSES.length % STATUS_PER_ROW + 1, - taskJobType: 'cubes', + columnGroup: (TASK_STATUSES.length % STATUS_PER_ROW) + 1, + taskJobType: 'cubes' }; if (!layout) { @@ -250,8 +264,8 @@ export const useDemoPipelineNodes = ( taskProgress: '3/4', taskType: 'java', taskTopic: 'Environment', - columnGroup: TASK_STATUSES.length % STATUS_PER_ROW + 1, - taskJobType: 'link', + columnGroup: (TASK_STATUSES.length % STATUS_PER_ROW) + 1, + taskJobType: 'link' }; if (!layout) { diff --git a/packages/demo-app-ts/src/demos/stylesDemo/StylePipelinesGroup.tsx b/packages/demo-app-ts/src/demos/stylesDemo/StylePipelinesGroup.tsx new file mode 100644 index 00000000..94ae6009 --- /dev/null +++ b/packages/demo-app-ts/src/demos/stylesDemo/StylePipelinesGroup.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + GraphElement, + Node, + observer, + ScaleDetailsLevel, + ShapeProps, + WithContextMenuProps, + WithDragNodeProps, + WithSelectionProps, + PipelinesDefaultGroup, +} from '@patternfly/react-topology'; + +export enum DataTypes { + Default, + Alternate +} + +type StylePipelinesGroupProps = { + element: GraphElement; + collapsible?: boolean; + collapsedWidth?: number; + collapsedHeight?: number; + onCollapseChange?: (group: Node, collapsed: boolean) => void; + getCollapsedShape?: (node: Node) => React.FunctionComponent; + collapsedShadowOffset?: number; // defaults to 10 +} & WithContextMenuProps & + WithDragNodeProps & + WithSelectionProps; + +const StylePipelinesGroup: React.FunctionComponent = ({ + element, + collapsedWidth, + collapsedHeight, + ...rest +}) => { + const data = element.getData(); + const detailsLevel = element.getGraph().getDetailsLevel(); + + const passedData = React.useMemo(() => { + const newData = { ...data }; + Object.keys(newData).forEach(key => { + if (newData[key] === undefined) { + delete newData[key]; + } + }); + return newData; + }, [data]); + + return ( + + + ); +}; + +export default observer(StylePipelinesGroup); diff --git a/packages/demo-app-ts/src/demos/stylesDemo/stylesComponentFactory.tsx b/packages/demo-app-ts/src/demos/stylesDemo/stylesComponentFactory.tsx index b747aa51..495c6fcb 100644 --- a/packages/demo-app-ts/src/demos/stylesDemo/stylesComponentFactory.tsx +++ b/packages/demo-app-ts/src/demos/stylesDemo/stylesComponentFactory.tsx @@ -140,4 +140,4 @@ const stylesComponentFactory: ComponentFactory = ( } }; -export default stylesComponentFactory; +export default stylesComponentFactory; \ No newline at end of file diff --git a/packages/module/src/components/nodes/labels/LabelActionIcon.tsx b/packages/module/src/components/nodes/labels/LabelActionIcon.tsx index 44610754..8cc17eae 100644 --- a/packages/module/src/components/nodes/labels/LabelActionIcon.tsx +++ b/packages/module/src/components/nodes/labels/LabelActionIcon.tsx @@ -2,10 +2,13 @@ import * as React from 'react'; import { useSize } from '../../../utils'; import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-components'; +import pipelineStyles from '../../../css/topology-pipelines'; interface LabelActionIconProps { className?: string; icon: React.ReactElement; + isIconExternal?: boolean; + hover?: boolean; onClick: (e: React.MouseEvent) => void; iconOffsetX?: number; iconOffsetY?: number; @@ -17,12 +20,15 @@ interface LabelActionIconProps { } const LabelActionIcon = React.forwardRef( - ({ icon, onClick, className, x, y, paddingX, height, iconOffsetX = 0, iconOffsetY = 0 }, actionRef) => { + ({ icon, isIconExternal, onClick, className, x, y, paddingX, height, iconOffsetX = 0, iconOffsetY = 0 }, actionRef) => { const [iconSize, iconRef] = useSize([icon, paddingX]); const iconWidth = iconSize?.width ?? 0; - const iconHeight = iconSize?.height ?? 0; + const iconHeight = iconSize?.height ?? 0; const iconY = (height - iconHeight) / 2; + const centerX = x + height / 2 - iconWidth / 2; + const centerY = y + height / 2 - iconHeight / 2; + const classes = css(styles.topologyNodeActionIcon, className); const handleClick = (e: React.MouseEvent) => { @@ -37,16 +43,16 @@ const LabelActionIcon = React.forwardRef( {iconSize && ( )} {icon} diff --git a/packages/module/src/components/nodes/labels/PipelinesNodeLabel.tsx b/packages/module/src/components/nodes/labels/PipelinesNodeLabel.tsx new file mode 100644 index 00000000..4483f1a3 --- /dev/null +++ b/packages/module/src/components/nodes/labels/PipelinesNodeLabel.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +// import styles from '../../../css/topology-components'; +import pipelineStyles from '../../../css/topology-pipelines'; +import { truncateMiddle } from '../../../utils/truncate-middle'; +import { createSvgIdUrl, useCombineRefs, useHover, useSize } from '../../../utils'; +import { WithContextMenuProps, WithDndDragProps } from '../../../behavior'; +import NodeShadows, { NODE_SHADOW_FILTER_ID_DANGER, NODE_SHADOW_FILTER_ID_HOVER } from '../NodeShadows'; +import LabelBadge from './LabelBadge'; +import LabelIcon from './LabelIcon'; +import LabelActionIcon from './LabelActionIcon'; +import { BadgeLocation, LabelPosition, NodeStatus } from '../../../types'; + +type PipelinesNodeLabelProps = { + children?: string; + className?: string; + paddingX?: number; + paddingY?: number; + x?: number; + y?: number; + position?: LabelPosition; + cornerRadius?: number; + status?: NodeStatus; + truncateLength?: number; // Defaults to 13 + labelIconClass?: string; // Icon to show in label + labelIcon?: React.ReactNode; + labelIconPadding?: number; + dragRef?: WithDndDragProps['dndDragRef']; + hover?: boolean; + dragging?: boolean; + edgeDragging?: boolean; + dropTarget?: boolean; + isExpanded?: boolean; + actionIcon?: React.ReactElement; + actionIconClassName?: string; + onActionIconClick?: (e: React.MouseEvent) => void; + badge?: string; + badgeColor?: string; + badgeTextColor?: string; + badgeBorderColor?: string; + badgeClassName?: string; + badgeLocation?: BadgeLocation; +} & Partial; + +/** + * Renders a `` component with a `` box behind. + */ +const PipelinesNodeLabel: React.FunctionComponent = ({ + children, + className, + paddingX = 0, + paddingY = 0, + cornerRadius = 22, + x = 0, + y = 0, + position = LabelPosition.bottom, + status, + badge, + badgeColor, + badgeTextColor, + badgeBorderColor, + badgeClassName, + badgeLocation = BadgeLocation.inner, + labelIconClass, + labelIcon, + labelIconPadding = 4, + isExpanded = false, + truncateLength, + dragRef, + hover, + dragging, + edgeDragging, + dropTarget, + actionIcon, + onActionIconClick, + ...other +}) => { + const [labelHover, labelHoverRef] = useHover(); + const refs = useCombineRefs(dragRef, typeof truncateLength === 'number' ? labelHoverRef : undefined); + + const [textSize, textRef] = useSize([children, truncateLength, className, labelHover]); + const [badgeSize, badgeRef] = useSize([badge]); + const [actionSize, actionRef] = useSize([actionIcon, paddingX]); + + const { + width, + height, + backgroundHeight, + startX, + startY, + badgeStartX, + badgeStartY, + actionStartX, + iconSpace, + } = React.useMemo(() => { + if (!textSize) { + return { + width: 0, + height: 0, + backgroundHeight: 0, + startX: 0, + startY: 0, + badgeStartX: 0, + badgeStartY: 0, + actionStartX: 0, + contextStartX: 0, + iconSpace: 0, + badgeSpace: 0 + }; + } + const badgeSpace = badge && badgeSize && badgeLocation === BadgeLocation.inner ? badgeSize.width + paddingX : 0; + const height = Math.max(textSize.height, badgeSize?.height ?? 0) + paddingY * 2; + const iconSpace = labelIconClass || labelIcon ? (height + paddingY * 0.5) / 2 : 0; + const actionSpace = actionIcon && actionSize ? actionSize.width : 0; + const primaryWidth = iconSpace + badgeSpace + paddingX + textSize.width + paddingX; + const width = primaryWidth; + + let startX: number; + let startY: number; + if (position === LabelPosition.top) { + startX = x - width / 2; + startY = -y - height - paddingY; + } else if (position === LabelPosition.right) { + startX = x + iconSpace; + startY = y - height / 2; + } else if (position === LabelPosition.left) { + startX = -width - paddingX; + startY = y - height / 2 + paddingY; + } else { + startX = x - width / 2 + iconSpace / 2; + startY = y; + } + const actionStartX = iconSpace + badgeSpace + paddingX + textSize.width + paddingX; + const contextStartX = actionStartX + actionSpace; + const backgroundHeight = height; + let badgeStartX = 0; + let badgeStartY = 0; + if (badgeSize) { + if (badgeLocation === BadgeLocation.below) { + badgeStartX = (width - badgeSize.width) / 2; + badgeStartY = height + paddingY; + } else { + badgeStartX = iconSpace + textSize.width + paddingX * 2; + badgeStartY = (height - badgeSize.height) / 2; + } + } + + return { + width, + height, + backgroundHeight, + startX, + startY, + actionStartX, + contextStartX, + badgeStartX, + badgeStartY, + iconSpace, + badgeSpace: badgeSize && badgeLocation === BadgeLocation.inner ? badgeSpace : 0 + }; + }, [ + textSize, + badge, + badgeSize, + badgeLocation, + paddingX, + paddingY, + labelIconClass, + labelIcon, + actionIcon, + actionSize, + position, + x, + y + ]); + + let filterId; + if (status === 'danger') { + filterId = NODE_SHADOW_FILTER_ID_DANGER; + } else if (hover || dragging || edgeDragging || dropTarget) { + filterId = NODE_SHADOW_FILTER_ID_HOVER; + } + + return ( + + + {textSize && ( + + )} + {textSize && badge && ( + + )} + {textSize && (labelIconClass || labelIcon) && ( + + )} + + {truncateLength > 0 && !labelHover ? truncateMiddle(children, { length: truncateLength }) : children} + + {textSize && actionIcon && ( + + )} + + ); +}; + +export default PipelinesNodeLabel; diff --git a/packages/module/src/components/nodes/labels/index.ts b/packages/module/src/components/nodes/labels/index.ts index 11dcc59c..e16cc915 100644 --- a/packages/module/src/components/nodes/labels/index.ts +++ b/packages/module/src/components/nodes/labels/index.ts @@ -3,3 +3,5 @@ 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 { default as PipelinesNodeLabel } from './PipelinesNodeLabel'; + diff --git a/packages/module/src/css/topology-components.css b/packages/module/src/css/topology-components.css index bdda4f56..d2af6b51 100644 --- a/packages/module/src/css/topology-components.css +++ b/packages/module/src/css/topology-components.css @@ -601,7 +601,7 @@ } .pf-topology__group__label > text { - fill: var(--pf-topology__group__label__text--Fill); + fill: white; font-size: var(--pf-v5-global--FontSize--sm); pointer-events: none; } @@ -843,5 +843,4 @@ .pf-topology-default-create-connector__create__cursor { fill: var(--pf-topology__create-connector-color--Fill); -} - +} \ No newline at end of file diff --git a/packages/module/src/css/topology-pipelines.css b/packages/module/src/css/topology-pipelines.css index 070a9637..1c45b6e3 100644 --- a/packages/module/src/css/topology-pipelines.css +++ b/packages/module/src/css/topology-pipelines.css @@ -101,6 +101,9 @@ --pf-topology-pipelines__status-icon--m-selected--m-running--color: var(--pf-v5-global--Color--light-100); --pf-topology-pipelines__status-icon--m-selected--m-idle--color: var(--pf-v5-global--Color--light-100); + /* pipelines node label */ + --pf-topology-pipelines__node__label__background--fill: var(--pf-v5-global--Color--light-100); + --pf-topology-pipelines__node__label__background--m-selected--fill: var(--pf-v5-global--active-color--100); /* when expression */ --pf-topology-pipelines__when-expression-background--Stroke: var(--pf-v5-global--BorderColor--200); @@ -555,3 +558,106 @@ .pf-topology-pipelines__status-icon-background.pf-m-idle.pf-m-selected { --pf-topology-pipelines__pill-background--Fill: var(--pf-topology-pipelines__pill-background--m-idle--Stroke); } + +.pf-topology__group__action__label { + fill: #fff; + stroke-width: 1; + stroke: var(--pf-v5-global--active-color--100) +} + +.pf-topology-pipelines__group__label>text { + fill: black; + font-size: var(--pf-v5-global--FontSize--sm); + pointer-events: none; +} + +.pf-topology-pipelines__group.pf-m-selected .pf-topology-pipelines__group__label>text { + fill: white; + font-size: var(--pf-v5-global--FontSize--sm); + pointer-events: none; +} + +.action-icon-collapsed > .pf-topology-pipelines__node__action-icon__icon > svg { + fill: var(--pf-v5-global--active-color--100);; +} + +.action-icon-collapsed .pf-topology-pipelines__node__action-icon__background:hover { + stroke: var(--pf-v5-global--active-color--100); + stroke-width: 2px; +} + + +.pf-topology__node__action-icon.action-icon-expanded .pf-topology-pipelines__node__action-icon__background { + fill: var(--pf-v5-global--active-color--100); +} +.action-icon-expanded .pf-topology-pipelines__node__action-icon__background:hover { + fill: var(--pf-v5-global--primary-color--200); +} + + + + +.action-icon-expanded > .pf-topology-pipelines__node__action-icon__icon > svg { + fill: white; +} + +.pf-topology-pipelines__node__background { + fill: var(--pf-topology__node__background--Fill); + stroke-width: var(--pf-topology__node__background--StrokeWidth); + stroke: var(--pf-topology__node__background--Stroke); +} + +.pf-topology-pipelines__node__action-icon { + cursor: pointer; + fill: var(--pf-v5-global--Color--light-100); +} + +.pf-topology-pipelines__node__label__background { + fill: var(--pf-topology-pipelines__node__label__background--fill); +} + +.pf-topology-pipelines__group-inner.pf-m-selected .pf-topology__group__background { + --pf-topology__group__background--Fill: var(--pf-topology__group--m-selected--topology__group__background--Fill); + --pf-topology__group__background--Stroke: var(--pf-topology__group--m-selected--topology__group__background--Stroke); +} + +.pf-topology-pipelines__group-inner.pf-m-hover .pf-topology__group__background { + --pf-topology__group__background--Stroke: var(--pf-topology__group--m-hover--topology__group__background--Stroke); + --pf-topology__group__label__node__label__background--Stroke: var(--pf-topology__group--m-hover--label__node__label__background--Stroke); +} + +.pf-topology-pipelines__group-inner.pf-m-hover.pf-m-selected .pf-topology__group__background { + --pf-topology__group__background--Fill: var(--pf-topology__group--m-selected--topology__group__background--Fill); + --pf-topology__group__background--Stroke: var(--pf-topology__group--m-selected--m-hover--topology__group__background--Stroke); + --pf-topology__group__label__node__label__background--Stroke: var(--pf-topology__group--m-hover--label__node__label__background--Stroke); +} + +.pf-topology__node__action-icon { + fill: var(--pf-v5-global--Color--light-100);; + stroke: var(--pf-v5-global--active-color--100) +} + +.action-icon-expanded svg { + fill: var(--pf-v5-global--Color--light-100); +} + +rect.pf-topology-pipelines__node__label__background.pf-m-selected + text { + fill: var(--pf-v5-global--Color--light-100); +} + +.pf-topology-pipelines__group.pf-m-selected .pf-topology-pipelines__node__label__background { + fill: var(--pf-v5-global--active-color--100); +} + +.pf-topology-pipelines__group.pf-m-selected .pf-topology-pipelines__node__label__background + text { + fill: var(--pf-v5-global--Color--light-100); +} + +.pf-topology-pipelines__group.pf-m-selected pf-topology-pipelines__group__label text { + fill: var(--pf-v5-global--Color--light-100); +} + +.pf-topology-pipelines__group.pf-m-selected .pf-topology-pipelines__group__background { + --pf-topology__group__background--Fill: var(--pf-topology__group--m-selected--topology__group__background--Fill); + --pf-topology__group__background--Stroke: var(--pf-topology__group--m-selected--topology__group__background--Stroke); +} diff --git a/packages/module/src/pipelines/components/groups/PipelinesDefaultGroup.tsx b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroup.tsx new file mode 100644 index 00000000..33c6bb18 --- /dev/null +++ b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroup.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { OnSelect, WithDndDragProps, ConnectDragSource, ConnectDropTarget, WithSelectionProps } from '../../../behavior'; +import { ShapeProps } from '../../../components'; +import { Dimensions } from '../../../geom'; +import { GraphElement, LabelPosition, BadgeLocation, isNode, Node } from '../../../types'; +import PipelinesDefaultGroupCollapsed from './PipelinesDefaultGroupCollapsed'; +import PipelinesDefaultGroupExpanded from './PipelinesDefaultGroupExpanded'; + +interface PipelinesDefaultGroupProps { + /** Additional content added to the node */ + children?: React.ReactNode; + /** Additional classes added to the group */ + className?: string; + /** The graph group node element to represent */ + element: GraphElement; + /** Flag if the node accepts drop operations */ + droppable?: boolean; + /** Flag if the current drag operation can be dropped on the node */ + canDrop?: boolean; + /** Flag if the node is the current drop target */ + dropTarget?: boolean; + /** Flag if the user is dragging the node */ + dragging?: boolean; + /** Flag if drag operation is a regroup operation */ + dragRegroupable?: boolean; + /** Flag if the user is hovering on the node */ + hover?: boolean; + /** Label for the node. Defaults to element.getLabel() */ + label?: string; // Defaults to element.getLabel() + /** Secondary label for the node */ + secondaryLabel?: string; + /** Flag to show the label */ + showLabel?: boolean; // Defaults to true + /** Position of the label, top or bottom. Defaults to element.getLabelPosition() or bottom */ + labelPosition?: LabelPosition; + /** The maximum length of the label before truncation */ + truncateLength?: number; + /** The Icon class to show in the label, ignored when labelIcon is specified */ + labelIconClass?: string; + /** The label icon component to show in the label, takes precedence over labelIconClass */ + labelIcon?: 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; + /** Flag if the group is collapsible */ + collapsible?: boolean; + /** Width of the collapsed group */ + collapsedWidth?: number; + /** Height of the collapsed group */ + collapsedHeight?: number; + /** Callback when the group is collapsed */ + onCollapseChange?: (group: Node, collapsed: boolean) => void; + /** Shape of the collapsed group */ + getCollapsedShape?: (node: Node) => React.FunctionComponent; + /** Shadow offset for the collapsed group */ + collapsedShadowOffset?: 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 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 whether to use hull layout or rect layout for expanded groups. Defaults to hull (true) */ + hulledOutline?: boolean; +} + +type PipelinesDefaultGroupInnerProps = Omit & { element: Node } & WithSelectionProps; + +const PipelinesDefaultGroupInner: React.FunctionComponent = observer( + ({ className, element, onCollapseChange, ...rest }) => { + const childCount = element.getAllNodeChildren().length; + const handleCollapse = (group: Node, collapsed: boolean): void => { + if (collapsed && rest.collapsedWidth !== undefined && rest.collapsedHeight !== undefined) { + group.setDimensions(new Dimensions(rest.collapsedWidth, rest.collapsedHeight)); + } + group.setCollapsed(collapsed); + onCollapseChange && onCollapseChange(group, collapsed); + }; + + if (element.isCollapsed()) { + return ( + + ); + } + return ( + + ); + } +); + +const PipelinesDefaultGroup: React.FunctionComponent = ({ + element, + ...rest +}: PipelinesDefaultGroupProps) => { + if (!isNode(element)) { + throw new Error('DefaultGroup must be used only on Node elements'); + } + + return ; +}; + +export default PipelinesDefaultGroup; diff --git a/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupCollapsed.tsx b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupCollapsed.tsx new file mode 100644 index 00000000..bc4a992f --- /dev/null +++ b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupCollapsed.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-topology/src/css/topology-pipelines'; +import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-alt-icon'; +import { WithDragNodeProps, WithSelectionProps, WithDndDropProps, WithContextMenuProps, useDragNode } from "../../../behavior"; +import { CollapsibleGroupProps, Stadium, Layer, PipelinesNodeLabel } from "../../../components"; +import { GROUPS_LAYER } from "../../../const"; +import { LabelPosition, BadgeLocation, Node } from "../../../types"; +import { useHover, useCombineRefs } from "../../../utils"; + +type PipelinesDefaultGroupCollapsedProps = { + children?: React.ReactNode; + className?: string; + element: Node; + droppable?: boolean; + canDrop?: boolean; + dropTarget?: boolean; + dragging?: boolean; + hover?: boolean; + label?: string; // Defaults to element.getLabel() + secondaryLabel?: string; + showLabel?: boolean; // Defaults to true + labelPosition?: LabelPosition; // Defaults to bottom + truncateLength?: number; // Defaults to 13 + labelIconClass?: string; // Icon to show in label + labelIcon?: string; + labelIconPadding?: number; + badge?: string; + badgeColor?: string; + badgeTextColor?: string; + badgeBorderColor?: string; + badgeClassName?: string; + badgeLocation?: BadgeLocation; +} & CollapsibleGroupProps & WithDragNodeProps & WithSelectionProps & WithDndDropProps & WithContextMenuProps; + +const PipelinesDefaultGroupCollapsed: React.FunctionComponent = ({ + className, + element, + collapsible, + selected, + onSelect, + children, + hover, + label, + showLabel = true, + truncateLength, + collapsedWidth, + collapsedHeight, + onCollapseChange, + collapsedShadowOffset = 8, + dragNodeRef, + canDrop, + dropTarget, + onContextMenu, + dragging, + labelPosition, + badge, + badgeColor, + badgeTextColor, + badgeBorderColor, + badgeClassName, + badgeLocation, + labelIconClass, + labelIcon, + labelIconPadding +}) => { + const [hovered, hoverRef] = useHover(); + const [labelHover, labelHoverRef] = useHover(); + const dragLabelRef = useDragNode()[1]; + const refs = useCombineRefs(hoverRef, dragNodeRef); + const isHover = hover !== undefined ? hover : hovered; + + const groupClassName = css( + styles.topologyPipelinesGroup, + className, + canDrop && 'pf-m-highlight', + canDrop && dropTarget && 'pf-m-drop-target', + dragging && 'pf-m-dragging', + selected && 'pf-m-selected' + ); + + return ( + + + + <> + + + + + + + {showLabel && ( + : undefined} + onActionIconClick={() => onCollapseChange(element, false)} + > + {label || element.getLabel()} + + )} + {children} + + ); +}; + +export default observer(PipelinesDefaultGroupCollapsed); diff --git a/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupExpanded.tsx b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupExpanded.tsx new file mode 100644 index 00000000..965cfb98 --- /dev/null +++ b/packages/module/src/pipelines/components/groups/PipelinesDefaultGroupExpanded.tsx @@ -0,0 +1,274 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { polygonHull } from 'd3-polygon'; +import { css } from '@patternfly/react-styles'; +import styles from '../../../css/topology-components'; +import pipelineStyles from '../../../css/topology-pipelines'; +import CollapseIcon from '@patternfly/react-icons/dist/esm/icons/compress-alt-icon'; +import { CollapsibleGroupProps, Layer, PipelinesNodeLabel } from "../../../components"; +import { WithDragNodeProps, WithSelectionProps, WithDndDropProps, WithContextMenuProps, useDragNode, useSvgAnchor } from "../../../behavior"; +import { GROUPS_LAYER, TOP_LAYER } from "../../../const"; +import { BadgeLocation, LabelPosition, isGraph, PointTuple, Node, NodeShape, NodeStyle } from "../../../types"; +import { useHover, useCombineRefs, maxPadding, hullPath } from "../../../utils"; +import Rect from '../../../geom/Rect'; + +type PipelinesDefaultGroupExpandedProps = { + className?: string; + element: Node; + droppable?: boolean; + canDrop?: boolean; + dropTarget?: boolean; + dragging?: boolean; + hover?: boolean; + label?: string; // Defaults to element.getLabel() + secondaryLabel?: string; + showLabel?: boolean; // Defaults to true + truncateLength?: number; // Defaults to 13 + badge?: string; + badgeColor?: string; + badgeTextColor?: string; + badgeBorderColor?: string; + badgeClassName?: string; + badgeLocation?: BadgeLocation; + labelIconClass?: string; // Icon to show in label + labelIcon?: string; + labelPosition?: LabelPosition; + labelIconPadding?: number; + hulledOutline?: boolean; +} & CollapsibleGroupProps & + WithDragNodeProps & + WithSelectionProps & + WithDndDropProps & + WithContextMenuProps; + +type PointWithSize = [number, number, number]; + +// Return the point whose Y is the largest or smallest based on the labelPosition value. +// If multiple points are found, compute the center X between them +// export for testing only +export function computeLabelLocation(points: PointWithSize[], labelPosition?: LabelPosition): PointWithSize { + let lowPoints: PointWithSize[]; + let highPoints: PointWithSize[]; + const threshold = 5; + + if (labelPosition === LabelPosition.top) { + points.forEach((p) => { + const delta = !highPoints ? -Infinity : Math.round(p[1]) - Math.round(highPoints[0][1]); + // If the difference is greater than the threshold, update the highest point + if (delta < -threshold) { + highPoints = [p]; + } else if (Math.abs(delta) <= threshold) { + if (!highPoints) { + highPoints = []; + } + highPoints.push(p); + } + }); + + // find min and max by x and y coordinates + const minX = highPoints.reduce((min, p) => Math.min(min, p[0]), Infinity); + const maxX = highPoints.reduce((max, p) => Math.max(max, p[0]), -Infinity); + const minY = highPoints.reduce((min, p) => Math.min(min, p[1]), Infinity); + // find max by size value + const maxSize = highPoints.reduce((max, p) => Math.max(max, p[2]), -Infinity); + + return [ + (minX + maxX) / 2, + minY, + // use the max size value + maxSize + ]; + } + + points.forEach(p => { + const delta = !lowPoints ? Infinity : Math.round(p[1]) - Math.round(lowPoints[0][1]); + if (delta > threshold) { + lowPoints = [p]; + } else if (Math.abs(delta) <= threshold) { + lowPoints.push(p); + } + }); + + const minX = lowPoints.reduce((acc, point) => { + return Math.min(acc, point[0]); + }, Number.POSITIVE_INFINITY); + const maxX = lowPoints.reduce((acc, point) => { + return Math.max(acc, point[0]); + }, Number.NEGATIVE_INFINITY); + const maxSize = lowPoints.reduce((acc, point) => { + return Math.max(acc, point[2]); + }, Number.NEGATIVE_INFINITY); + return [ + (minX + maxX) / 2, + lowPoints[0][1], + maxSize, + ]; +} + +const PipelinesDefaultGroupExpanded: React.FunctionComponent = ({ + className, + element, + collapsible, + selected, + onSelect, + hover, + label, + showLabel = true, + truncateLength, + dndDropRef, + droppable, + canDrop, + dropTarget, + dragging, + dragNodeRef, + badge, + badgeColor, + badgeTextColor, + badgeBorderColor, + badgeClassName, + badgeLocation, + labelIconClass, + labelIcon, + labelPosition, + labelIconPadding, + onCollapseChange, + hulledOutline = true, +}) => { + const [hovered, hoverRef] = useHover(); + const [labelHover, labelHoverRef] = useHover(); + const dragLabelRef = useDragNode()[1]; + const refs = useCombineRefs(hoverRef, dragNodeRef); + const isHover = hover !== undefined ? hover : hovered; + const anchorRef = useSvgAnchor(); + const outlineRef = useCombineRefs(dndDropRef, anchorRef); + const labelLocation = React.useRef(); + const pathRef = React.useRef(); + const boxRef = React.useRef(null); + + let parent = element.getParent(); + let altGroup = false; + while (!isGraph(parent)) { + altGroup = !altGroup; + parent = parent.getParent(); + } + + // cast to number and coerce + const padding = maxPadding(element.getStyle().padding ?? 17); + const extraPadding = labelPosition === LabelPosition.top ? 25 : 25; // add extra padding if label on top + const hullPadding = (point: PointWithSize | PointTuple) => (point[2] || 0) + padding + extraPadding; + + if (!droppable || (hulledOutline && !pathRef.current) || (!hulledOutline && !boxRef.current) || !labelLocation.current) { + const children = element.getNodes().filter(c => c.isVisible()); + if (children.length === 0) { + return null; + } + const points: (PointWithSize | PointTuple)[] = []; + children.forEach(c => { + if (c.getNodeShape() === NodeShape.circle) { + const bounds = c.getBounds(); + const { width, height } = bounds; + const { x, y } = bounds.getCenter(); + const radius = Math.max(width, height) / 2; + points.push([x, y, radius] as PointWithSize); + } else { + // add all 4 corners + const { width, height, x, y } = c.getBounds(); + points.push([x, y, 0] as PointWithSize); + points.push([x + width, y, 0] as PointWithSize); + points.push([x, y + height, 0] as PointWithSize); + points.push([x + width, y + height, 0] as PointWithSize); + } + }); + + if (hulledOutline) { + const hullPoints: (PointWithSize | PointTuple)[] = + points.length > 2 ? polygonHull(points as PointTuple[]) : (points as PointTuple[]); + if (!hullPoints) { + return null; + } + + // change the box only when not dragging + pathRef.current = hullPath(hullPoints as PointTuple[], hullPadding); + + // Compute the location of the group label. + labelLocation.current = computeLabelLocation(hullPoints as PointWithSize[], labelPosition); + } else { + boxRef.current = element.getBounds(); + labelLocation.current = + labelPosition === LabelPosition.top + ? [boxRef.current.x + boxRef.current.width / 2, boxRef.current.y, 0] + : [boxRef.current.x + boxRef.current.width / 2, boxRef.current.y + boxRef.current.height, 0]; + } + } + + const groupClassName = css( + pipelineStyles.topologyPipelinesGroup, + className, + altGroup && 'pf-m-alt-group', + canDrop && 'pf-m-highlight', + dragging && 'pf-m-dragging', + selected && 'pf-m-selected' + ); + const innerGroupClassName = css( + pipelineStyles.topologyPipelinesGroupInner, + className, + altGroup && 'pf-m-alt-group', + canDrop && 'pf-m-highlight', + dragging && 'pf-m-dragging', + selected && 'pf-m-selected', + (isHover || labelHover) && 'pf-m-hover', + canDrop && dropTarget && 'pf-m-drop-target' + ); + + const outlinePadding = hulledOutline ? hullPadding(labelLocation.current) : 0; + const labelGap = 20; + const startX = labelLocation.current[0]; + const startY = + labelPosition === LabelPosition.top + ? labelLocation.current[1] - outlinePadding - labelGap + : labelLocation.current[1] + outlinePadding - labelGap; + + return ( + + + + {hulledOutline ? ( + + ) : ( + + )} + + + {showLabel && (label || element.getLabel()) && ( + + : undefined} + onActionIconClick={() => onCollapseChange(element, true)} + > + {label || element.getLabel()} + + + )} + + ); +}; + +export default observer(PipelinesDefaultGroupExpanded); diff --git a/packages/module/src/pipelines/components/groups/index.ts b/packages/module/src/pipelines/components/groups/index.ts index ff1e08c3..c4d5386b 100644 --- a/packages/module/src/pipelines/components/groups/index.ts +++ b/packages/module/src/pipelines/components/groups/index.ts @@ -1 +1,4 @@ export { default as DefaultTaskGroup } from './DefaultTaskGroup'; +export { default as PipelinesDefaultGroup } from './PipelinesDefaultGroup'; +export { default as PipelinesDefaultGroupCollapsed } from './PipelinesDefaultGroupCollapsed'; +export { default as PipelinesDefaultGroupExpanded } from './PipelinesDefaultGroupExpanded';