diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
index d0f282724f..c4cbb886fe 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
@@ -33,6 +33,7 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
page_size: 200,
page: pageNo,
});
+
if (data.next) {
return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results));
}
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx
new file mode 100644
index 0000000000..39bc048a6a
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowJobsAPI } from '@api';
+import WorkflowOutput from './WorkflowOutput';
+
+jest.mock('@api');
+
+const job = {
+ id: 1,
+ name: 'Foo JT',
+ status: 'successful',
+};
+
+const mockWorkflowJobNodes = [
+ {
+ id: 8,
+ success_nodes: [10],
+ failure_nodes: [],
+ always_nodes: [9],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'A Playbook',
+ status: 'successful',
+ type: 'job',
+ },
+ },
+ },
+ {
+ id: 9,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'A Project Update',
+ status: 'successful',
+ type: 'project_update',
+ },
+ },
+ },
+ {
+ id: 10,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'An Inventory Source Sync',
+ status: 'successful',
+ type: 'inventory_update',
+ },
+ },
+ },
+ {
+ id: 11,
+ success_nodes: [9],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'Pause',
+ status: 'successful',
+ type: 'workflow_approval',
+ },
+ },
+ },
+];
+
+describe('WorkflowOutput', () => {
+ let wrapper;
+ beforeEach(() => {
+ WorkflowJobsAPI.readNodes.mockResolvedValue({
+ data: {
+ count: mockWorkflowJobNodes.length,
+ results: mockWorkflowJobNodes,
+ },
+ });
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('renders successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(0);
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
+ expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
+ });
+
+ test('error shown to user when error thrown fetching workflow job nodes', async () => {
+ WorkflowJobsAPI.readNodes.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx
index b3295916e3..40aca8f683 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx
@@ -173,7 +173,8 @@ function WorkflowOutputGraph() {
setLinkHelp(link)}
+ mouseLeave={() => setLinkHelp(null)}
/>
)),
nodes.map(node => {
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx
new file mode 100644
index 0000000000..1d0f978860
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import WorkflowOutputGraph from './WorkflowOutputGraph';
+
+const workflowContext = {
+ links: [
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'success',
+ type: 'link',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ type: 'link',
+ },
+ {
+ source: {
+ id: 5,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'success',
+ type: 'link',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ type: 'link',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 5,
+ },
+ linkType: 'success',
+ type: 'link',
+ },
+ ],
+ nodePositions: {
+ 1: { label: '', width: 72, height: 40, x: 36, y: 85 },
+ 2: { label: '', width: 180, height: 60, x: 282, y: 40 },
+ 3: { label: '', width: 180, height: 60, x: 582, y: 130 },
+ 4: { label: '', width: 180, height: 60, x: 582, y: 30 },
+ 5: { label: '', width: 180, height: 60, x: 282, y: 140 },
+ },
+ nodes: [
+ {
+ id: 1,
+ type: 'node',
+ },
+ {
+ id: 2,
+ type: 'node',
+ job: {
+ name: 'Foo JT',
+ type: 'job',
+ status: 'successful',
+ elapsed: 60,
+ },
+ },
+ {
+ id: 3,
+ type: 'node',
+ },
+ {
+ id: 4,
+ type: 'node',
+ },
+ {
+ id: 5,
+ type: 'node',
+ },
+ ],
+ showLegend: false,
+ showTools: false,
+};
+
+describe('WorkflowOutputGraph', () => {
+ beforeEach(() => {
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterEach(() => {
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('mounts successfully', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+
+ test('tools and legend are shown when flags are true', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
+ expect(wrapper.find('WorkflowTools')).toHaveLength(1);
+ });
+
+ test('nodes and links are properly rendered', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
+ expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
+ expect(wrapper.find('#link-2-4')).toHaveLength(1);
+ expect(wrapper.find('#link-2-3')).toHaveLength(1);
+ expect(wrapper.find('#link-5-3')).toHaveLength(1);
+ expect(wrapper.find('#link-1-2')).toHaveLength(1);
+ expect(wrapper.find('#link-1-5')).toHaveLength(1);
+ });
+
+ test('proper help text is shown when hovering over links and nodes', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ wrapper.find('g#node-2').simulate('mouseenter');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Name)).toEqual(
+ true
+ );
+ expect(
+ wrapper.find('WorkflowNodeHelp').containsMatchingElement(Foo JT)
+ ).toEqual(true);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Type)).toEqual(
+ true
+ );
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(Job Template)
+ ).toEqual(true);
+ expect(
+ wrapper.find('WorkflowNodeHelp').contains(Job Status)
+ ).toEqual(true);
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(Successful)
+ ).toEqual(true);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Elapsed)).toEqual(
+ true
+ );
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(00:01:00)
+ ).toEqual(true);
+ wrapper.find('g#node-2').simulate('mouseleave');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ wrapper.find('g#link-2-3').simulate('mouseenter');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowLinkHelp').contains(Run)).toEqual(true);
+ expect(
+ wrapper.find('WorkflowLinkHelp').containsMatchingElement(Always)
+ ).toEqual(true);
+ wrapper.find('g#link-2-3').simulate('mouseleave');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx
index 022cad9de7..b7ae3028dc 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx
@@ -1,13 +1,13 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
-import { shape } from 'prop-types';
+import { func, shape } from 'prop-types';
import {
generateLine,
getLinePoints,
getLinkOverlayPoints,
} from '@components/Workflow/WorkflowUtils';
-function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
+function WorkflowOutputLink({ link, mouseEnter, mouseLeave }) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState();
@@ -17,11 +17,13 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
const handleLinkMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current);
setHovering(true);
+ mouseEnter();
};
const handleLinkMouseLeave = () => {
ref.current.parentNode.prepend(ref.current);
setHovering(null);
+ mouseLeave();
};
useEffect(() => {
@@ -56,8 +58,8 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
/>
onUpdateLinkHelp(link)}
- onMouseLeave={() => onUpdateLinkHelp(null)}
+ onMouseEnter={() => mouseEnter()}
+ onMouseLeave={() => mouseLeave()}
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
/>
@@ -67,6 +69,8 @@ function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
WorkflowOutputLink.propTypes = {
link: shape().isRequired,
+ mouseEnter: func.isRequired,
+ mouseLeave: func.isRequired,
};
export default WorkflowOutputLink;
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx
index 09830aab45..1fe47c070e 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx
@@ -35,7 +35,8 @@ describe('WorkflowOutputLink', () => {
{}}
+ mouseEnter={() => {}}
+ mouseLeave={() => {}}
/>
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx
index 48bcba9617..616358f24e 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx
@@ -103,7 +103,7 @@ function WorkflowOutputNode({ history, i18n, mouseEnter, mouseLeave, node }) {
{node.job ? (
<>
-
+ {node.job.status && }
{node.job.name}
{secondsToHHMMSS(node.job.elapsed)}
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx
index 27c5bb594a..3f0410ce55 100644
--- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx
@@ -76,6 +76,7 @@ function WorkflowOutputToolbar({ i18n, job }) {
dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain"
@@ -85,6 +86,7 @@ function WorkflowOutputToolbar({ i18n, job }) {
dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain"