Adds unit test coverage to some of the workflow output components. Also adds support for hovering on workflow results links to see the edge type (success/fail/always).

This commit is contained in:
mabashian 2020-01-22 16:46:34 -05:00
parent e394d0a6f6
commit fd146dde30
11 changed files with 582 additions and 85 deletions

View File

@ -1,7 +1,10 @@
import React, { Fragment, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { arrayOf, bool, shape, func } from 'prop-types';
import { calcZoomAndFit, getZoomTranslate } from '@util/workflow';
import {
getScaleAndOffsetToFit,
getTranslatePointsForZoom,
} from '@util/workflow';
import {
WorkflowOutputLink,
WorkflowOutputNode,
@ -10,6 +13,7 @@ import {
import {
WorkflowHelp,
WorkflowKey,
WorkflowLinkHelp,
WorkflowNodeHelp,
WorkflowTools,
} from '@components/Workflow';
@ -23,6 +27,7 @@ function WorkflowOutputGraph({
showKey,
showTools,
}) {
const [linkHelp, setLinkHelp] = useState();
const [nodeHelp, setNodeHelp] = useState();
const [zoomPercentage, setZoomPercentage] = useState(100);
const svgRef = useRef(null);
@ -83,7 +88,17 @@ function WorkflowOutputGraph({
};
const handleZoomChange = newScale => {
const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale);
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const currentScaleAndOffset = d3.zoomTransform(
d3.select(svgRef.current).node()
);
const [translateX, translateY] = getTranslatePointsForZoom(
svgBoundingClientRect,
currentScaleAndOffset,
newScale
);
d3.select(svgRef.current).call(
zoomRef.transform,
@ -93,9 +108,27 @@ function WorkflowOutputGraph({
};
const handleFitGraph = () => {
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
const { k: currentScale } = d3.zoomTransform(
d3.select(svgRef.current).node()
);
const gBoundingClientRect = d3
.select(gRef.current)
.node()
.getBoundingClientRect();
const gBBoxDimensions = d3
.select(gRef.current)
.node()
.getBBox();
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const [scaleToFit, yTranslate] = getScaleAndOffsetToFit(
gBoundingClientRect,
svgBoundingClientRect,
gBBoxDimensions,
currentScale
);
d3.select(svgRef.current).call(
@ -118,19 +151,9 @@ function WorkflowOutputGraph({
// Attempt to zoom the graph to fit the available screen space
useEffect(() => {
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
);
setZoomPercentage(scaleToFit * 100);
handleFitGraph();
// We only want this to run once (when the component mounts)
// Including zoomRef.transform in the deps array will cause this to
// Including handleFitGraph in the deps array will cause this to
// run very frequently.
// Discussion: https://github.com/facebook/create-react-app/issues/6880
// and https://github.com/facebook/react/issues/15865 amongst others
@ -139,9 +162,10 @@ function WorkflowOutputGraph({
return (
<Fragment>
{nodeHelp && (
{(nodeHelp || linkHelp) && (
<WorkflowHelp>
<WorkflowNodeHelp node={nodeHelp} />
{nodeHelp && <WorkflowNodeHelp node={nodeHelp} />}
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
</WorkflowHelp>
)}
<svg
@ -160,6 +184,7 @@ function WorkflowOutputGraph({
key={`link-${link.source.id}-${link.target.id}`}
link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={setLinkHelp}
/>
)),
nodes.map(node => {

View File

@ -1,11 +1,32 @@
import React, { useEffect, useState } from 'react';
import { shape } from 'prop-types';
import { generateLine, getLinePoints } from '@util/workflow';
import {
generateLine,
getLinePoints,
getLinkOverlayPoints,
} from '@util/workflow';
function WorkflowOutputLink({ link, nodePositions }) {
function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) {
const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState();
const [pathStroke, setPathStroke] = useState('#CCCCCC');
const handleLinkMouseEnter = () => {
const linkEl = document.getElementById(
`link-${link.source.id}-${link.target.id}`
);
linkEl.parentNode.appendChild(linkEl);
setHovering(true);
};
const handleLinkMouseLeave = () => {
const linkEl = document.getElementById(
`link-${link.source.id}-${link.target.id}`
);
linkEl.parentNode.prepend(linkEl);
setHovering(null);
};
useEffect(() => {
if (link.linkType === 'failure') {
setPathStroke('#d9534f');
@ -25,14 +46,22 @@ function WorkflowOutputLink({ link, nodePositions }) {
return (
<g
className="WorkflowGraph-link"
id={`link-${link.source.id}-${link.target.id}`}
onMouseEnter={handleLinkMouseEnter}
onMouseLeave={handleLinkMouseLeave}
>
<path
className="WorkflowGraph-linkPath"
d={pathD}
stroke={pathStroke}
strokeWidth="2px"
<polygon
fill="#E1E1E1"
id={`link-${link.source.id}-${link.target.id}-overlay`}
opacity={hovering ? '1' : '0'}
points={getLinkOverlayPoints(link, nodePositions)}
/>
<path d={pathD} stroke={pathStroke} strokeWidth="2px" />
<polygon
onMouseEnter={() => onUpdateLinkHelp(link)}
onMouseLeave={() => onUpdateLinkHelp(null)}
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
/>
</g>
);

View File

@ -0,0 +1,42 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputLink from './WorkflowOutputLink';
const link = {
source: {
id: 1,
},
target: {
id: 2,
},
};
const nodePositions = {
1: {
width: 72,
height: 40,
x: 0,
y: 0,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
};
describe('WorkflowOutputLink', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputLink
link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={() => {}}
/>
</svg>
);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -114,9 +114,7 @@ function WorkflowOutputNode({
: i18n._(t`DELETED`)}
</p>
</JobTopLine>
<Elapsed>
<span>{secondsToHHMMSS(node.job.elapsed)}</span>
</Elapsed>
<Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>
</Fragment>
) : (
<NodeDefaultLabel>

View File

@ -0,0 +1,102 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputNode from './WorkflowOutputNode';
const nodeWithJT = {
id: 2,
job: {
elapsed: 7,
id: 9000,
name: 'Automation JT',
status: 'successful',
type: 'job',
},
unifiedJobTemplate: {
id: 77,
name: 'Automation JT',
unified_job_type: 'job',
},
};
const nodeWithoutJT = {
id: 2,
job: {
elapsed: 7,
id: 9000,
name: 'Automation JT',
status: 'successful',
type: 'job',
},
};
const nodePositions = {
1: {
width: 72,
height: 40,
x: 0,
y: 0,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
};
describe('WorkflowOutputNode', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
nodePositions={nodePositions}
/>
</svg>
);
expect(wrapper).toHaveLength(1);
});
test('node contents displayed correctly when Job and Job Template exist', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
nodePositions={nodePositions}
/>
</svg>
);
expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true);
expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07');
});
test('node contents displayed correctly when Job Template deleted', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithoutJT}
nodePositions={nodePositions}
/>
</svg>
);
expect(wrapper.contains(<p>DELETED</p>)).toEqual(true);
expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07');
});
test('node contents displayed correctly when Job deleted', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{ id: 2 }}
nodePositions={nodePositions}
/>
</svg>
);
expect(wrapper.text()).toBe('DELETED');
});
});

View File

@ -0,0 +1,21 @@
import React from 'react';
import { mount } from 'enzyme';
import WorkflowOutputStartNode from './WorkflowOutputStartNode';
const nodePositions = {
1: {
x: 0,
y: 0,
},
};
describe('WorkflowOutputStartNode', () => {
test('mounts successfully', () => {
const wrapper = mount(
<svg>
<WorkflowOutputStartNode nodePositions={nodePositions} />
</svg>
);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1,51 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
const job = {
id: 1,
status: 'successful',
};
describe('WorkflowOutputToolbar', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<WorkflowOutputToolbar
job={job}
keyShown={false}
nodes={[]}
onKeyToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
);
expect(wrapper).toHaveLength(1);
});
test('shows correct number of nodes', () => {
const nodes = [
{
id: 1,
},
{
id: 2,
},
{
id: 3,
isDeleted: true,
},
];
const wrapper = mountWithContexts(
<WorkflowOutputToolbar
job={job}
keyShown={false}
nodes={nodes}
onKeyToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
);
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
expect(wrapper.find('Badge').text()).toBe('1');
});
});

View File

@ -5,9 +5,9 @@ import styled from 'styled-components';
import { arrayOf, bool, func, shape } from 'prop-types';
import * as d3 from 'd3';
import {
calcZoomAndFit,
getScaleAndOffsetToFit,
constants as wfConstants,
getZoomTranslate,
getTranslatePointsForZoom,
} from '@util/workflow';
import {
WorkflowHelp,
@ -161,7 +161,17 @@ function VisualizerGraph({
};
const handleZoomChange = newScale => {
const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale);
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const currentScaleAndOffset = d3.zoomTransform(
d3.select(svgRef.current).node()
);
const [translateX, translateY] = getTranslatePointsForZoom(
svgBoundingClientRect,
currentScaleAndOffset,
newScale
);
d3.select(svgRef.current).call(
zoomRef.transform,
@ -171,9 +181,27 @@ function VisualizerGraph({
};
const handleFitGraph = () => {
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
const { k: currentScale } = d3.zoomTransform(
d3.select(svgRef.current).node()
);
const gBoundingClientRect = d3
.select(gRef.current)
.node()
.getBoundingClientRect();
const gBBoxDimensions = d3
.select(gRef.current)
.node()
.getBBox();
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const [scaleToFit, yTranslate] = getScaleAndOffsetToFit(
gBoundingClientRect,
svgBoundingClientRect,
gBBoxDimensions,
currentScale
);
d3.select(svgRef.current).call(
@ -196,19 +224,9 @@ function VisualizerGraph({
// Attempt to zoom the graph to fit the available screen space
useEffect(() => {
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
);
setZoomPercentage(scaleToFit * 100);
handleFitGraph();
// We only want this to run once (when the component mounts)
// Including zoomRef.transform in the deps array will cause this to
// Including handleFitGraph in the deps array will cause this to
// run very frequently.
// Discussion: https://github.com/facebook/create-react-app/issues/6880
// and https://github.com/facebook/react/issues/15865 amongst others

View File

@ -116,7 +116,6 @@ function VisualizerLink({
return (
<LinkG
className="WorkflowGraph-link"
id={`link-${link.source.id}-${link.target.id}`}
ignorePointerEvents={addingLink}
onMouseEnter={handleLinkMouseEnter}
@ -128,12 +127,7 @@ function VisualizerLink({
opacity={hovering ? '1' : '0'}
points={getLinkOverlayPoints(link, nodePositions)}
/>
<path
className="WorkflowGraph-linkPath"
d={pathD}
stroke={pathStroke}
strokeWidth="2px"
/>
<path d={pathD} stroke={pathStroke} strokeWidth="2px" />
<polygon
onMouseEnter={() => onUpdateLinkHelp(link)}
onMouseLeave={() => onUpdateLinkHelp(null)}

View File

@ -11,24 +11,15 @@ export const constants = {
rootH: 40,
};
export function calcZoomAndFit(gRef, svgRef) {
const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node());
const gBoundingClientRect = d3
.select(gRef)
.node()
.getBoundingClientRect();
export function getScaleAndOffsetToFit(
gBoundingClientRect,
svgBoundingClientRect,
gBBoxDimensions,
currentScale
) {
gBoundingClientRect.height /= currentScale;
gBoundingClientRect.width /= currentScale;
const gBBoxDimensions = d3
.select(gRef)
.node()
.getBBox();
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
// For some reason the root width needs to be added?
gBoundingClientRect.width += constants.rootW;
@ -96,19 +87,19 @@ export function getLinePoints(link, nodePositions) {
];
}
export function getLinkOverlayPoints(d, nodePositions) {
export function getLinkOverlayPoints(link, nodePositions) {
const sourceX =
nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
let sourceY =
normalizeY(nodePositions, nodePositions[d.source.id].y) +
nodePositions[d.source.id].height / 2;
const targetX = nodePositions[d.target.id].x - 1;
normalizeY(nodePositions, nodePositions[link.source.id].y) +
nodePositions[link.source.id].height / 2;
const targetX = nodePositions[link.target.id].x - 1;
const targetY =
normalizeY(nodePositions, nodePositions[d.target.id].y) +
nodePositions[d.target.id].height / 2;
normalizeY(nodePositions, nodePositions[link.target.id].y) +
nodePositions[link.target.id].height / 2;
// There's something off with the math on the root node...
if (d.source.id === 1) {
if (link.source.id === 1) {
sourceY += 10;
}
const slope = (targetY - sourceY) / (targetX - sourceX);
@ -177,18 +168,19 @@ export function layoutGraph(nodes, links) {
return g;
}
export function getZoomTranslate(svgRef, newScale) {
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const current = d3.zoomTransform(d3.select(svgRef).node());
const origScale = current.k;
export function getTranslatePointsForZoom(
svgBoundingClientRect,
currentScaleAndOffset,
newScale
) {
const origScale = currentScaleAndOffset.k;
const unscaledOffsetX =
(current.x +
(currentScaleAndOffset.x +
(svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) /
2) /
origScale;
const unscaledOffsetY =
(current.y +
(currentScaleAndOffset.y +
(svgBoundingClientRect.height * origScale -
svgBoundingClientRect.height) /
2) /

View File

@ -0,0 +1,225 @@
import {
getScaleAndOffsetToFit,
generateLine,
getLinePoints,
getLinkOverlayPoints,
layoutGraph,
getTranslatePointsForZoom,
} from './workflow';
describe('getScaleAndOffsetToFit', () => {
const gBoundingClientRect = {
x: 36,
y: 11,
width: 798,
height: 160,
top: 11,
right: 834,
bottom: 171,
left: 36,
};
const svgBoundingClientRect = {
x: 0,
y: 56,
width: 1680,
height: 455,
top: 56,
right: 1680,
bottom: 511,
left: 0,
};
const gBBoxDimensions = {
x: 36,
y: -45,
width: 726,
height: 160,
};
const currentScale = 1;
test('returns correct scale and y-offset for zooming the graph to best fit the available space', () => {
expect(
getScaleAndOffsetToFit(
gBoundingClientRect,
svgBoundingClientRect,
gBBoxDimensions,
currentScale
)
).toEqual([1.931, 159.91499999999996]);
});
});
describe('generateLine', () => {
test('returns correct svg path string', () => {
expect(
generateLine([
{
x: 0,
y: 0,
},
{
x: 10,
y: 10,
},
])
).toEqual('M0,0L10,10');
expect(
generateLine([
{
x: 900,
y: 44,
},
{
x: 5000,
y: 359,
},
])
).toEqual('M900,44L5000,359');
});
});
describe('getLinePoints', () => {
const link = {
source: {
id: 1,
},
target: {
id: 2,
},
};
const nodePositions = {
1: {
width: 72,
height: 40,
x: 36,
y: 130,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
};
test('returns the correct endpoints of the line', () => {
expect(getLinePoints(link, nodePositions)).toEqual([
{ x: 109, y: 30 },
{ x: 281, y: -60 },
]);
});
});
describe('getLinkOverlayPoints', () => {
const link = {
source: {
id: 1,
},
target: {
id: 2,
},
};
const nodePositions = {
1: {
width: 72,
height: 40,
x: 36,
y: 130,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
};
test('returns the four points of the polygon that will act as the overlay for the link', () => {
expect(getLinkOverlayPoints(link, nodePositions)).toEqual(
'281,-50.970992003685446 109,39.02900799631457 109,20.97099200368546 281,-69.02900799631456'
);
});
});
describe('layoutGraph', () => {
const nodes = [
{
id: 1,
},
{
id: 2,
},
{
id: 3,
},
{
id: 4,
},
];
const links = [
{
source: {
id: 1,
},
target: {
id: 2,
},
},
{
source: {
id: 1,
},
target: {
id: 4,
},
},
{
source: {
id: 2,
},
target: {
id: 3,
},
},
{
source: {
id: 4,
},
target: {
id: 3,
},
},
];
test('returns the correct dimensions and positions for the nodes', () => {
expect(layoutGraph(nodes, links)._nodes).toEqual({
1: { height: 40, label: '', width: 72, x: 36, y: 75 },
2: { height: 60, label: '', width: 180, x: 282, y: 30 },
3: { height: 60, label: '', width: 180, x: 582, y: 75 },
4: { height: 60, label: '', width: 180, x: 282, y: 120 },
});
});
});
describe('getTranslatePointsForZoom', () => {
const svgBoundingClientRect = {
x: 0,
y: 56,
width: 1680,
height: 455,
top: 56,
right: 1680,
bottom: 511,
left: 0,
};
const currentScaleAndOffset = {
k: 2,
x: 0,
y: 167.5,
};
const newScale = 1.9;
test('returns the correct translation point', () => {
expect(
getTranslatePointsForZoom(
svgBoundingClientRect,
currentScaleAndOffset,
newScale
)
).toEqual([42, 170.5]);
});
});