Skip to content

Commit

Permalink
Merge pull request #2680 from jenny-s51/v2-pipeline-artifact-nodes
Browse files Browse the repository at this point in the history
Feat(pipelines): Adds support for artifact nodes and vertical layout
  • Loading branch information
openshift-merge-bot[bot] authored Apr 5, 2024
2 parents 71f078c + aa31c0d commit 1e1b52e
Show file tree
Hide file tree
Showing 13 changed files with 290 additions and 45 deletions.
9 changes: 4 additions & 5 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@patternfly/react-styles": "^5.2.1",
"@patternfly/react-table": "^5.2.1",
"@patternfly/react-tokens": "^5.2.1",
"@patternfly/react-topology": "^5.1.0",
"@patternfly/react-topology": "^5.3.0-prerelease.5",
"@patternfly/react-virtualized-extension": "^5.0.0",
"@types/classnames": "^2.3.1",
"axios": "^1.6.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PipelineRunKFv2, PipelineSpecVariable } from '~/concepts/pipelines/kfTypes';
import { createNode } from '~/concepts/topology';
import { PipelineNodeModelExpanded } from '~/concepts/topology/types';
import { createArtifactNode } from '~/concepts/topology/utils';
import {
composeArtifactType,
parseComponentsForArtifactRelationship,
Expand Down Expand Up @@ -64,10 +65,12 @@ export const usePipelineTaskTopology = (
const id = artifactId ?? artifactKey;

nodes.push(
createNode({
createArtifactNode({
id,
label,
artifactType: data.schemaTitle,
runAfter: [taskId],
status: translateStatusForNode(status?.state),
}),
);

Expand Down
30 changes: 30 additions & 0 deletions frontend/src/concepts/topology/PipelineTaskEdge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';
import {
DEFAULT_SPACER_NODE_TYPE,
GraphElement,
Edge,
EdgeTerminalType,
observer,
TaskEdge,
} from '@patternfly/react-topology';

interface PipelineTaskEdgeProps {
element: GraphElement;
}

const PipelineTaskEdge: React.FC<PipelineTaskEdgeProps> = ({ element, ...props }) => {
const edge = element as Edge;
return (
<TaskEdge
element={edge}
endTerminalType={
edge.getTarget().getType() !== DEFAULT_SPACER_NODE_TYPE
? EdgeTerminalType.directional
: undefined
}
{...props}
/>
);
};

export default observer(PipelineTaskEdge);
11 changes: 7 additions & 4 deletions frontend/src/concepts/topology/PipelineVisualizationSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ const PipelineVisualizationSurface: React.FC<PipelineVisualizationSurfaceProps>
const controller = useVisualizationController();
const [error, setError] = React.useState<Error | null>();
React.useEffect(() => {
// PF Bug
// TODO: Pipeline Topology weirdly doesn't set a width and height on spacer nodes -- but they do when using finally spacer nodes
const spacerNodes = getSpacerNodes(nodes).map((s) => ({ ...s, width: 1, height: 1 }));
const renderNodes = [...spacerNodes, ...nodes];
const spacerNodes = getSpacerNodes(nodes);

// Dagre likes the root nodes to be first in the order
const renderNodes = [...spacerNodes, ...nodes].sort(
(a, b) => (a.runAfterTasks?.length ?? 0) - (b.runAfterTasks?.length ?? 0),
);

// TODO: We can have a weird edge issue if the node is off by a few pixels vertically from the center
const edges = getEdgesFromNodes(renderNodes);
try {
Expand Down
19 changes: 9 additions & 10 deletions frontend/src/concepts/topology/TaskEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
observer,
Edge,
integralShapePath,
DEFAULT_SPACER_NODE_TYPE,
ConnectorArrow,
DagreLayoutOptions,
} from '@patternfly/react-topology';

interface TaskEdgeProps {
Expand All @@ -24,22 +24,21 @@ const TaskEdge: React.FunctionComponent<TaskEdgeProps> = ({
const endPoint = element.getEndPoint();
const groupClassName = css(styles.topologyEdge, className);
const startIndent: number = element.getData()?.indent || 0;
const verticalLayout =
(element.getGraph().getLayoutOptions?.() as DagreLayoutOptions).rankdir === 'TB';

return (
<g data-test-id="task-handler" className={groupClassName}>
<path
fillOpacity={0}
d={integralShapePath(startPoint, endPoint, startIndent, nodeSeparation)}
d={integralShapePath(startPoint, endPoint, startIndent, nodeSeparation, verticalLayout)}
shapeRendering="geometricPrecision"
/>

{element.getTarget().getType() !== DEFAULT_SPACER_NODE_TYPE ? (
<ConnectorArrow
className={styles.topologyEdge}
startPoint={endPoint.clone().translate(-1, 0)}
endPoint={endPoint}
/>
) : null}
<ConnectorArrow
className={styles.topologyEdge}
startPoint={endPoint.clone().translate(0, -1)}
endPoint={endPoint}
/>
</g>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/concepts/topology/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export const PIPELINE_LAYOUT = 'PipelineLayout';
export const PIPELINE_NODE_SEPARATION_VERTICAL = 100;

export const NODE_WIDTH = 100;
export const NODE_HEIGHT = 30;
export const NODE_HEIGHT = 35;
163 changes: 163 additions & 0 deletions frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import React, { LegacyRef } from 'react';
import {
TaskNode,
DEFAULT_WHEN_OFFSET,
Node,
WhenDecorator,
NodeModel,
WithSelectionProps,
observer,
useAnchor,
AnchorEnd,
getRunStatusModifier,
ScaleDetailsLevel,
useHover,
TaskNodeSourceAnchor,
TaskNodeTargetAnchor,
GraphElement,
} from '@patternfly/react-topology';
import { ListIcon, MonitoringIcon } from '@patternfly/react-icons';
import { TaskNodeProps } from '@patternfly/react-topology/dist/esm/pipelines/components/nodes/TaskNode';
import { css } from '@patternfly/react-styles';
import { StandardTaskNodeData } from '~/concepts/topology/types';

const ICON_PADDING = 8;

type IconTaskNodeProps = {
element: Node<NodeModel, StandardTaskNodeData>;
} & WithSelectionProps;

const IconTaskNode: React.FC<IconTaskNodeProps> = observer(({ element, selected, onSelect }) => {
const data = element.getData();
const status = data?.status;
const bounds = element.getBounds();
const iconSize = bounds.height - ICON_PADDING * 2;

const runStatusModifier = status && getRunStatusModifier(status);

useAnchor(
React.useCallback(
(node: Node) => new TaskNodeSourceAnchor(node, ScaleDetailsLevel.high, 0, true),
[],
),
AnchorEnd.source,
);
useAnchor(
React.useCallback(
(node: Node) => new TaskNodeTargetAnchor(node, 0, ScaleDetailsLevel.high, 0, true),
[],
),
AnchorEnd.target,
);

return (
<g
className={css(
'pf-topology-pipelines__pill',
runStatusModifier,
selected && 'pf-m-selected',
onSelect && 'pf-m-selectable',
)}
onClick={onSelect}
>
<rect
className="pf-topology-pipelines__pill-background"
x={0}
y={0}
width={bounds.width}
height={bounds.height}
rx={bounds.height / 2}
/>
<g
transform={`translate(${(bounds.width - iconSize) / 2}, ${ICON_PADDING})`}
color={
selected
? 'var(--pf-v5-global--icon--Color--dark--light)'
: 'var(--pf-v5-global--icon--Color--light)'
}
>
{data?.artifactType === 'system.Metrics' ? (
<MonitoringIcon width={iconSize} height={iconSize} />
) : (
<ListIcon width={iconSize} height={iconSize} />
)}
</g>
</g>
);
});

type ArtifactTaskNodeInnerProps = WithSelectionProps & {
element: Node<NodeModel, StandardTaskNodeData>;
} & Omit<TaskNodeProps, 'element'> & { element: Node };

const ArtifactTaskNodeInner: React.FC<ArtifactTaskNodeInnerProps> = observer(
({ element, selected, onSelect, ...rest }) => {
const bounds = element.getBounds();
const [isHover, hoverRef] = useHover();
const detailsLevel = element.getGraph().getDetailsLevel();
const data = element.getData();
const scale = element.getGraph().getScale();
const iconSize = 24;
const whenDecorator = data?.whenStatus ? (
<WhenDecorator element={element} status={data.whenStatus} leftOffset={DEFAULT_WHEN_OFFSET} />
) : null;
const upScale = 1 / scale;

return (
<g
className={css('pf-topology__pipelines__task-node')}
ref={hoverRef as LegacyRef<SVGGElement>}
>
{isHover || detailsLevel !== ScaleDetailsLevel.high ? (
<g>
<TaskNode
nameLabelClass="artifact-node-label"
hideDetailsAtMedium
truncateLength={30}
element={element}
hover
selected={selected}
onSelect={onSelect}
status={data?.status}
scaleNode={isHover}
{...rest}
>
{whenDecorator}
</TaskNode>
{!isHover && detailsLevel !== ScaleDetailsLevel.high ? (
<g
transform={`translate(0, ${
(bounds.height - iconSize * upScale) / 2
}) scale(${upScale})`}
>
<g transform="translate(4, 4)">
<g
color={
selected
? 'var(--pf-v5-global--icon--Color--dark--light)'
: 'var(--pf-v5-global--icon--Color--light)'
}
>
{data?.artifactType === 'system.Metrics' ? <MonitoringIcon /> : <ListIcon />}
</g>
</g>
</g>
) : null}
</g>
) : (
<IconTaskNode selected={selected} onSelect={onSelect} element={element} />
)}
</g>
);
},
);

type ArtifactTaskNodeProps = {
element: GraphElement;
} & WithSelectionProps;

const ArtifactTaskNode: React.FC<ArtifactTaskNodeProps> = ({ element, ...rest }) => (
<ArtifactTaskNodeInner element={element as Node} {...rest} />
);

export default ArtifactTaskNode;
53 changes: 41 additions & 12 deletions frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,59 @@
import * as React from 'react';
import {
TaskNode,
DEFAULT_WHEN_OFFSET,
Node,
DEFAULT_WHEN_SIZE,
GraphElement,
observer,
RunStatus,
ScaleDetailsLevel,
TaskNode,
useHover,
WhenDecorator,
NodeModel,
WithContextMenuProps,
WithSelectionProps,
observer,
} from '@patternfly/react-topology';
import { StandardTaskNodeData } from '~/concepts/topology/types';

type DemoTaskNodeProps = WithSelectionProps & {
element: Node<NodeModel, StandardTaskNodeData>;
};
type StandardTaskNodeProps = {
element: GraphElement;
} & WithContextMenuProps &
WithSelectionProps;

const StandardTaskNode: React.FC<DemoTaskNodeProps> = ({ element, onSelect, selected }) => {
const StandardTaskNode: React.FunctionComponent<StandardTaskNodeProps> = ({
element,
onSelect,
selected,
...rest
}) => {
const data = element.getData();
const [hover, hoverRef] = useHover();
const detailsLevel = element.getGraph().getDetailsLevel();

const whenDecorator = data?.whenStatus ? (
<WhenDecorator element={element} status={data.whenStatus} leftOffset={DEFAULT_WHEN_OFFSET} />
) : null;

return (
<TaskNode onSelect={onSelect} selected={selected} element={element} status={data?.status}>
{whenDecorator}
</TaskNode>
<g ref={hoverRef as React.LegacyRef<SVGGElement>}>
<TaskNode
element={element}
onSelect={onSelect}
selected={selected}
scaleNode={hover && detailsLevel !== ScaleDetailsLevel.high}
status={data?.status}
hideDetailsAtMedium
hiddenDetailsShownStatuses={[
RunStatus.Succeeded,
RunStatus.Cancelled,
RunStatus.Failed,
RunStatus.Running,
]}
whenOffset={data?.whenStatus ? DEFAULT_WHEN_OFFSET : 0}
whenSize={data?.whenStatus ? DEFAULT_WHEN_SIZE : 0}
{...rest}
>
{whenDecorator}
</TaskNode>
</g>
);
};

Expand Down
Loading

0 comments on commit 1e1b52e

Please sign in to comment.