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

View File

@@ -1,11 +1,32 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { shape } from 'prop-types'; 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 [pathD, setPathD] = useState();
const [pathStroke, setPathStroke] = useState('#CCCCCC'); 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(() => { useEffect(() => {
if (link.linkType === 'failure') { if (link.linkType === 'failure') {
setPathStroke('#d9534f'); setPathStroke('#d9534f');
@@ -25,14 +46,22 @@ function WorkflowOutputLink({ link, nodePositions }) {
return ( return (
<g <g
className="WorkflowGraph-link"
id={`link-${link.source.id}-${link.target.id}`} id={`link-${link.source.id}-${link.target.id}`}
onMouseEnter={handleLinkMouseEnter}
onMouseLeave={handleLinkMouseLeave}
> >
<path <polygon
className="WorkflowGraph-linkPath" fill="#E1E1E1"
d={pathD} id={`link-${link.source.id}-${link.target.id}-overlay`}
stroke={pathStroke} opacity={hovering ? '1' : '0'}
strokeWidth="2px" 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> </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`)} : i18n._(t`DELETED`)}
</p> </p>
</JobTopLine> </JobTopLine>
<Elapsed> <Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>
<span>{secondsToHHMMSS(node.job.elapsed)}</span>
</Elapsed>
</Fragment> </Fragment>
) : ( ) : (
<NodeDefaultLabel> <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 { arrayOf, bool, func, shape } from 'prop-types';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { import {
calcZoomAndFit, getScaleAndOffsetToFit,
constants as wfConstants, constants as wfConstants,
getZoomTranslate, getTranslatePointsForZoom,
} from '@util/workflow'; } from '@util/workflow';
import { import {
WorkflowHelp, WorkflowHelp,
@@ -161,7 +161,17 @@ function VisualizerGraph({
}; };
const handleZoomChange = newScale => { 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( d3.select(svgRef.current).call(
zoomRef.transform, zoomRef.transform,
@@ -171,9 +181,27 @@ function VisualizerGraph({
}; };
const handleFitGraph = () => { const handleFitGraph = () => {
const [scaleToFit, yTranslate] = calcZoomAndFit( const { k: currentScale } = d3.zoomTransform(
gRef.current, d3.select(svgRef.current).node()
svgRef.current );
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( d3.select(svgRef.current).call(
@@ -196,19 +224,9 @@ function VisualizerGraph({
// Attempt to zoom the graph to fit the available screen space // Attempt to zoom the graph to fit the available screen space
useEffect(() => { useEffect(() => {
const [scaleToFit, yTranslate] = calcZoomAndFit( handleFitGraph();
gRef.current,
svgRef.current
);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
);
setZoomPercentage(scaleToFit * 100);
// We only want this to run once (when the component mounts) // 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. // run very frequently.
// Discussion: https://github.com/facebook/create-react-app/issues/6880 // Discussion: https://github.com/facebook/create-react-app/issues/6880
// and https://github.com/facebook/react/issues/15865 amongst others // and https://github.com/facebook/react/issues/15865 amongst others

View File

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

View File

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