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"