mirror of
https://github.com/ansible/awx.git
synced 2026-02-13 11:45:00 -03:30
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:
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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) /
|
||||||
|
|||||||
225
awx/ui_next/src/util/workflow.test.jsx
Normal file
225
awx/ui_next/src/util/workflow.test.jsx
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user