Adds basic unit test coverage to visualizer components (not including modals).

This commit is contained in:
mabashian 2020-02-03 17:42:39 -05:00
parent 1d0e752989
commit e3cfdb74ba
13 changed files with 1018 additions and 59 deletions

View File

@ -1,13 +1,17 @@
import React from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
let wrapper;
const dispatch = jest.fn();
const job = {
id: 1,
status: 'successful',
};
const workflowContext = {
nodes: [],
showLegend: false,
@ -15,16 +19,7 @@ const workflowContext = {
};
describe('WorkflowOutputToolbar', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<WorkflowStateContext.Provider value={workflowContext}>
<WorkflowOutputToolbar job={job} />
</WorkflowStateContext.Provider>
);
expect(wrapper).toHaveLength(1);
});
test('shows correct number of nodes', () => {
beforeAll(() => {
const nodes = [
{
id: 1,
@ -37,12 +32,31 @@ describe('WorkflowOutputToolbar', () => {
isDeleted: true,
},
];
const wrapper = mountWithContexts(
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
<WorkflowOutputToolbar job={job} />
</WorkflowStateContext.Provider>
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
<WorkflowOutputToolbar job={job} />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Shows correct number of nodes', () => {
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
expect(wrapper.find('Badge').text()).toBe('1');
});
test('Toggle Legend button dispatches as expected', () => {
wrapper.find('CompassIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LEGEND' });
});
test('Toggle Tools button dispatches as expected', () => {
wrapper.find('WrenchIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_TOOLS' });
});
});

View File

@ -11,6 +11,7 @@ function DeleteAllNodesModal({ i18n }) {
<AlertModal
actions={[
<Button
id="confirm-delete-all-nodes"
key="remove"
variant="danger"
aria-label={i18n._(t`Confirm removal of all nodes`)}
@ -19,6 +20,7 @@ function DeleteAllNodesModal({ i18n }) {
{i18n._(t`Remove`)}
</Button>,
<Button
id="cancel-delete-all-nodes"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel node removal`)}

View File

@ -24,6 +24,7 @@ function LinkModal({ header, i18n, onConfirm }) {
onClose={() => dispatch({ type: 'CANCEL_LINK_MODAL' })}
actions={[
<Button
id="link-confirm"
key="save"
variant="primary"
aria-label={i18n._(t`Save link changes`)}
@ -32,6 +33,7 @@ function LinkModal({ header, i18n, onConfirm }) {
{i18n._(t`Save`)}
</Button>,
<Button
id="link-cancel"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel link changes`)}
@ -44,6 +46,7 @@ function LinkModal({ header, i18n, onConfirm }) {
<FormGroup fieldId="link-select" label={i18n._(t`Run`)}>
<AnsibleSelect
id="link-select"
name="linkType"
value={linkType}
data={[
{

View File

@ -0,0 +1,207 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { WorkflowJobTemplateNodesAPI, WorkflowJobTemplatesAPI } from '@api';
import Visualizer from './Visualizer';
jest.mock('@api');
const template = {
id: 1,
name: 'Foo WFJT',
summary_fields: {
user_capabilities: {
edit: true,
delete: true,
start: true,
schedule: true,
copy: true,
},
},
};
const mockWorkflowNodes = [
{
id: 8,
success_nodes: [10],
failure_nodes: [],
always_nodes: [9],
summary_fields: {
unified_job_template: {
id: 14,
name: 'A Playbook',
type: 'job_template',
},
},
},
{
id: 9,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
summary_fields: {
unified_job_template: {
id: 14,
name: 'A Project Update',
type: 'project',
},
},
},
{
id: 10,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
summary_fields: {
unified_job_template: {
elapsed: 10,
name: 'An Inventory Source Sync',
type: 'inventory_source',
},
},
},
{
id: 11,
success_nodes: [9],
failure_nodes: [],
always_nodes: [],
summary_fields: {
unified_job_template: {
id: 14,
name: 'Pause',
type: 'workflow_approval_template',
},
},
},
];
describe('Visualizer', () => {
let wrapper;
beforeAll(() => {
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
data: {
count: mockWorkflowNodes.length,
results: mockWorkflowNodes,
},
});
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,
});
});
afterAll(() => {
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(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('ContentError')).toHaveLength(0);
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
expect(wrapper.find('VisualizerNode')).toHaveLength(4);
expect(wrapper.find('VisualizerLink')).toHaveLength(5);
});
test('Successfully deletes all nodes', async () => {
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('DeleteAllNodesModal').length).toBe(0);
wrapper.find('TrashAltIcon').simulate('click');
expect(wrapper.find('DeleteAllNodesModal').length).toBe(1);
wrapper.find('button#confirm-delete-all-nodes').simulate('click');
expect(wrapper.find('VisualizerStartScreen')).toHaveLength(1);
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(8);
expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(9);
expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(10);
expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(11);
});
test('Successfully changes link type', async () => {
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('LinkEditModal').length).toBe(0);
wrapper.find('g#link-2-3').simulate('mouseenter');
wrapper.find('#link-edit').simulate('click');
expect(wrapper.find('LinkEditModal').length).toBe(1);
act(() => {
wrapper
.find('LinkEditModal')
.find('AnsibleSelect')
.prop('onChange')(null, 'success');
});
wrapper.find('button#link-confirm').simulate('click');
expect(wrapper.find('LinkEditModal').length).toBe(0);
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
expect(
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
).toHaveBeenCalledWith(8, 9);
expect(
WorkflowJobTemplateNodesAPI.associateSuccessNode
).toHaveBeenCalledWith(8, 9);
});
test('Error shown to user when error thrown fetching workflow nodes', async () => {
WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(
<svg>
<Visualizer template={template} />
</svg>
);
});
wrapper.update();
expect(wrapper.find('ContentError')).toHaveLength(1);
});
});

View File

@ -283,8 +283,8 @@ function VisualizerGraph({ i18n, readOnly }) {
key={`link-${link.source.id}-${link.target.id}`}
link={link}
readOnly={readOnly}
onUpdateHelpText={setHelpText}
onUpdateLinkHelp={setLinkHelp}
updateLinkHelp={newLinkHelp => setLinkHelp(newLinkHelp)}
updateHelpText={newHelpText => setHelpText(newHelpText)}
/>
);
}
@ -297,8 +297,8 @@ function VisualizerGraph({ i18n, readOnly }) {
key={`node-${node.id}`}
node={node}
readOnly={readOnly}
onUpdateHelpText={setHelpText}
updateNodeHelp={setNodeHelp}
updateHelpText={newHelpText => setHelpText(newHelpText)}
updateNodeHelp={newNodeHelp => setNodeHelp(newNodeHelp)}
{...(addingLink && {
onMouseOver: () => drawPotentialLinkToNode(node),
})}

View File

@ -0,0 +1,226 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { WorkflowStateContext } from '@contexts/Workflow';
import VisualizerGraph from './VisualizerGraph';
const workflowContext = {
links: [
{
source: {
id: 1,
},
target: {
id: 2,
},
linkType: 'always',
},
{
source: {
id: 1,
},
target: {
id: 5,
},
linkType: 'always',
},
{
source: {
id: 2,
},
target: {
id: 4,
},
linkType: 'success',
},
{
source: {
id: 2,
},
target: {
id: 3,
},
linkType: 'always',
},
{
source: {
id: 5,
},
target: {
id: 3,
},
linkType: 'success',
},
],
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,
},
{
id: 2,
unifiedJobTemplate: {
name: 'Foo JT',
type: 'job_template',
},
},
{
id: 3,
},
{
id: 4,
},
{
id: 5,
},
],
showLegend: false,
showTools: false,
};
describe('VisualizerGraph', () => {
beforeAll(() => {
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,
});
});
afterAll(() => {
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(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<VisualizerGraph readOnly={false} />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper).toHaveLength(1);
});
test('tools and legend are shown when flags are true', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider
value={{ ...workflowContext, showLegend: true, showTools: true }}
>
<VisualizerGraph readOnly={false} />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
expect(wrapper.find('WorkflowTools')).toHaveLength(1);
});
test('nodes and links are properly rendered', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<VisualizerGraph readOnly={false} />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
expect(wrapper.find('VisualizerNode')).toHaveLength(4);
expect(wrapper.find('VisualizerLink')).toHaveLength(5);
expect(wrapper.find('g#link-2-4')).toHaveLength(1);
expect(wrapper.find('g#link-2-3')).toHaveLength(1);
expect(wrapper.find('g#link-5-3')).toHaveLength(1);
expect(wrapper.find('g#link-1-2')).toHaveLength(1);
expect(wrapper.find('g#link-1-5')).toHaveLength(1);
});
test('proper help text is shown when hovering over nodes', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<VisualizerGraph readOnly={false} />
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
wrapper
.find('g#node-2')
.find('foreignObject')
.first()
.simulate('mouseenter');
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Name</b>)).toEqual(
true
);
expect(
wrapper.find('WorkflowNodeHelp').containsMatchingElement(<dd>Foo JT</dd>)
).toEqual(true);
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Type</b>)).toEqual(
true
);
expect(
wrapper
.find('WorkflowNodeHelp')
.containsMatchingElement(<dd>Job Template</dd>)
).toEqual(true);
wrapper
.find('g#node-2')
.find('foreignObject')
.first()
.simulate('mouseleave');
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
});
test('proper help text is shown when hovering over links', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={workflowContext}>
<VisualizerGraph readOnly={false} />
</WorkflowStateContext.Provider>
</svg>
);
wrapper.find('#link-2-3-overlay').simulate('mouseenter');
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
expect(wrapper.find('WorkflowLinkHelp').contains(<b>Run</b>)).toEqual(true);
expect(
wrapper.find('WorkflowLinkHelp').containsMatchingElement(<dd>Always</dd>)
).toEqual(true);
wrapper.find('#link-2-3-overlay').simulate('mouseleave');
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
});
});

View File

@ -25,9 +25,9 @@ const LinkG = styled.g`
function VisualizerLink({
i18n,
link,
onUpdateHelpText,
onUpdateLinkHelp,
updateLinkHelp,
readOnly,
updateHelpText,
}) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
@ -43,7 +43,7 @@ function VisualizerLink({
id="link-add-node"
key="add"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({
type: 'START_ADD_NODE',
@ -52,9 +52,9 @@ function VisualizerLink({
});
}}
onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Add a new node between these two nodes`))
updateHelpText(i18n._(t`Add a new node between these two nodes`))
}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseLeave={() => updateHelpText(null)}
>
<PlusIcon />
</WorkflowActionTooltipItem>
@ -68,18 +68,26 @@ function VisualizerLink({
<WorkflowActionTooltipItem
id="link-edit"
key="edit"
onClick={() => dispatch({ type: 'SET_LINK_TO_EDIT', value: link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => onUpdateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_LINK_TO_EDIT', value: link });
}}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => updateHelpText(null)}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
<WorkflowActionTooltipItem
id="link-delete"
key="delete"
onClick={() => dispatch({ type: 'START_DELETE_LINK', link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => onUpdateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
dispatch({ type: 'START_DELETE_LINK', link });
}}
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => updateHelpText(null)}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -124,14 +132,15 @@ function VisualizerLink({
>
<polygon
fill="#E1E1E1"
id={`link-${link.source.id}-${link.target.id}-overlay`}
id={`link-${link.source.id}-${link.target.id}-background`}
opacity={hovering ? '1' : '0'}
points={getLinkOverlayPoints(link, nodePositions)}
/>
<path d={pathD} stroke={pathStroke} strokeWidth="2px" />
<polygon
onMouseEnter={() => onUpdateLinkHelp(link)}
onMouseLeave={() => onUpdateLinkHelp(null)}
id={`link-${link.source.id}-${link.target.id}-overlay`}
onMouseEnter={() => updateLinkHelp(link)}
onMouseLeave={() => updateLinkHelp(null)}
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
/>
@ -149,8 +158,8 @@ function VisualizerLink({
VisualizerLink.propTypes = {
link: shape().isRequired,
readOnly: bool.isRequired,
onUpdateHelpText: func.isRequired,
onUpdateLinkHelp: func.isRequired,
updateHelpText: func.isRequired,
updateLinkHelp: func.isRequired,
};
export default withI18n()(VisualizerLink);

View File

@ -0,0 +1,147 @@
import React from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import VisualizerLink from './VisualizerLink';
const link = {
source: {
id: 2,
},
target: {
id: 3,
},
linkType: 'success',
};
const mockedContext = {
addingLink: false,
nodePositions: {
1: {
width: 72,
height: 40,
x: 0,
y: 0,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
3: {
width: 180,
height: 60,
x: 564,
y: 40,
},
},
};
const dispatch = jest.fn();
const updateHelpText = jest.fn();
const updateLinkHelp = jest.fn();
describe('VisualizerLink', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={mockedContext}>
<svg>
<VisualizerLink
link={link}
readOnly={false}
updateHelpText={updateHelpText}
updateLinkHelp={updateLinkHelp}
/>
</svg>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Displays action tooltip on hover and updates help text on hover', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper
.find('g')
.first()
.simulate('mouseenter');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(1);
expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(3);
wrapper
.find('g')
.first()
.simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper
.find('#link-2-3-overlay')
.first()
.simulate('mouseenter');
expect(updateLinkHelp).toHaveBeenCalledWith(link);
wrapper
.find('#link-2-3-overlay')
.first()
.simulate('mouseleave');
expect(updateLinkHelp).toHaveBeenCalledWith(null);
});
test('Add Node tooltip action hover/click updates help text and dispatches properly', () => {
wrapper
.find('g')
.first()
.simulate('mouseenter');
wrapper.find('#link-add-node').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith(
'Add a new node between these two nodes'
);
wrapper.find('#link-add-node').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#link-add-node').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'START_ADD_NODE',
sourceNodeId: 2,
targetNodeId: 3,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Edit tooltip action hover/click updates help text and dispatches properly', () => {
wrapper
.find('g')
.first()
.simulate('mouseenter');
wrapper.find('#link-edit').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Edit this link');
wrapper.find('#link-edit').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#link-edit').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_LINK_TO_EDIT',
value: link,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
wrapper
.find('g')
.first()
.simulate('mouseenter');
wrapper.find('#link-delete').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Delete this link');
wrapper.find('#link-delete').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#link-delete').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'START_DELETE_LINK',
link,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
});

View File

@ -33,20 +33,21 @@ const NodeContents = styled.div`
props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'};
`;
const NodeDefaultLabel = styled.p`
const NodeResourceName = styled.p`
margin-top: 20px;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
`;
NodeResourceName.displayName = 'NodeResourceName';
function VisualizerNode({
i18n,
node,
onMouseOver,
readOnly,
onUpdateHelpText,
updateHelpText,
updateNodeHelp,
}) {
const ref = useRef(null);
@ -62,7 +63,7 @@ function VisualizerNode({
ref.current.parentNode.appendChild(ref.current);
setHovering(true);
if (addingLink) {
onUpdateHelpText(
updateHelpText(
node.isInvalidLinkTarget
? i18n._(
t`Invalid link target. Unable to link to children or ancestor nodes. Graph cycles are not supported.`
@ -76,7 +77,7 @@ function VisualizerNode({
const handleNodeMouseLeave = () => {
setHovering(false);
if (addingLink) {
onUpdateHelpText(null);
updateHelpText(null);
}
};
@ -91,12 +92,12 @@ function VisualizerNode({
id="node-details"
key="details"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
>
<InfoIcon />
</WorkflowActionTooltipItem>
@ -109,12 +110,12 @@ function VisualizerNode({
id="node-add"
key="add"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({ type: 'START_ADD_NODE', sourceNodeId: node.id });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => updateHelpText(null)}
>
<PlusIcon />
</WorkflowActionTooltipItem>,
@ -123,12 +124,12 @@ function VisualizerNode({
id="node-edit"
key="edit"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
@ -136,14 +137,14 @@ function VisualizerNode({
id="node-link"
key="link"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SELECT_SOURCE_FOR_LINKING', node });
}}
onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Link to an available node`))
updateHelpText(i18n._(t`Link to an available node`))
}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseLeave={() => updateHelpText(null)}
>
<LinkIcon />
</WorkflowActionTooltipItem>,
@ -151,12 +152,12 @@ function VisualizerNode({
id="node-delete"
key="delete"
onClick={() => {
onUpdateHelpText(null);
updateHelpText(null);
setHovering(false);
dispatch({ type: 'SET_NODE_TO_DELETE', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => onUpdateHelpText(null)}
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => updateHelpText(null)}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -198,11 +199,11 @@ function VisualizerNode({
y="1"
>
<NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}>
<NodeDefaultLabel>
<NodeResourceName>
{node.unifiedJobTemplate
? node.unifiedJobTemplate.name
: i18n._(t`DELETED`)}
</NodeDefaultLabel>
</NodeResourceName>
</NodeContents>
</foreignObject>
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
@ -221,7 +222,7 @@ VisualizerNode.propTypes = {
node: shape().isRequired,
onMouseOver: func,
readOnly: bool.isRequired,
onUpdateHelpText: func.isRequired,
updateHelpText: func.isRequired,
updateNodeHelp: func.isRequired,
};

View File

@ -0,0 +1,230 @@
import React from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import VisualizerNode from './VisualizerNode';
const mockedContext = {
addingLink: false,
addLinkSourceNode: null,
nodePositions: {
1: {
width: 72,
height: 40,
x: 0,
y: 0,
},
2: {
width: 180,
height: 60,
x: 282,
y: 40,
},
},
};
const nodeWithJT = {
id: 2,
unifiedJobTemplate: {
id: 77,
name: 'Automation JT',
type: 'job_template',
},
};
const dispatch = jest.fn();
const updateHelpText = jest.fn();
const updateNodeHelp = jest.fn();
describe('VisualizerNode', () => {
describe('Node with unified job template', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={mockedContext}>
<svg>
<VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
readOnly={false}
updateHelpText={updateHelpText}
updateNodeHelp={updateNodeHelp}
/>
</svg>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Displays unified job template name inside node', () => {
expect(wrapper.find('NodeResourceName').text()).toBe('Automation JT');
});
test('Displays action tooltip on hover and updates help text on hover', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper.find('VisualizerNode').simulate('mouseenter');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(1);
expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(5);
wrapper.find('VisualizerNode').simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper
.find('foreignObject')
.first()
.simulate('mouseenter');
expect(updateNodeHelp).toHaveBeenCalledWith(nodeWithJT);
wrapper
.find('foreignObject')
.first()
.simulate('mouseleave');
expect(updateNodeHelp).toHaveBeenCalledWith(null);
});
test('Add tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('#node-add').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Add a new node');
wrapper.find('#node-add').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#node-add').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'START_ADD_NODE',
sourceNodeId: 2,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Edit tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('#node-edit').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Edit this node');
wrapper.find('#node-edit').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#node-edit').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_EDIT',
value: nodeWithJT,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Details tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('#node-details').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('View node details');
wrapper.find('#node-details').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#node-details').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_VIEW',
value: nodeWithJT,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Link tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('#node-link').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Link to an available node');
wrapper.find('#node-link').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#node-link').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SELECT_SOURCE_FOR_LINKING',
node: nodeWithJT,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
wrapper.find('VisualizerNode').simulate('mouseenter');
wrapper.find('#node-delete').simulate('mouseenter');
expect(updateHelpText).toHaveBeenCalledWith('Delete this node');
wrapper.find('#node-delete').simulate('mouseleave');
expect(updateHelpText).toHaveBeenCalledWith(null);
wrapper.find('#node-delete').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_DELETE',
value: nodeWithJT,
});
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
});
});
describe('Node actions while adding a new link', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider
value={{
...mockedContext,
addingLink: true,
addLinkSourceNode: 323,
}}
>
<svg>
<VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
readOnly={false}
updateHelpText={updateHelpText}
updateNodeHelp={updateNodeHelp}
/>
</svg>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Displays correct help text when hovering over node while adding link', () => {
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
wrapper.find('VisualizerNode').simulate('mouseenter');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
expect(updateHelpText).toHaveBeenCalledWith(
'Click to create a new link to this node.'
);
wrapper.find('VisualizerNode').simulate('mouseleave');
expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
expect(updateHelpText).toHaveBeenCalledWith(null);
});
test('Dispatches properly when node is clicked', () => {
wrapper
.find('foreignObject')
.first()
.simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_ADD_LINK_TARGET_NODE',
value: nodeWithJT,
});
});
});
describe('Node without unified job template', () => {
test('Displays DELETED text inside node when unified job template is missing', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowStateContext.Provider value={mockedContext}>
<VisualizerNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{
id: 2,
}}
readOnly={false}
updateHelpText={() => {}}
updateNodeHelp={() => {}}
/>
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper).toHaveLength(1);
expect(wrapper.find('NodeResourceName').text()).toBe('DELETED');
});
});
});

View File

@ -0,0 +1,22 @@
import React from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import VisualizerStartScreen from './VisualizerStartScreen';
const dispatch = jest.fn();
describe('VisualizerStartScreen', () => {
test('dispatches properly when start button clicked', () => {
const wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<VisualizerStartScreen />
</WorkflowDispatchContext.Provider>
);
expect(wrapper).toHaveLength(1);
wrapper.find('Button').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'START_ADD_NODE',
sourceNodeId: 1,
});
});
});

View File

@ -99,7 +99,11 @@ function VisualizerToolbar({ i18n, onClose, onSave, template }) {
</ActionButton>
</Tooltip>
<VerticalSeparator />
<Button variant="primary" onClick={onSave}>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
onClick={onSave}
>
{i18n._(t`Save`)}
</Button>
<VerticalSeparator />

View File

@ -0,0 +1,94 @@
import React from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import VisualizerToolbar from './VisualizerToolbar';
let wrapper;
const close = jest.fn();
const dispatch = jest.fn();
const save = jest.fn();
const template = {
id: 1,
name: 'Test JT',
};
const workflowContext = {
nodes: [],
showLegend: false,
showTools: false,
};
describe('VisualizerToolbar', () => {
beforeAll(() => {
const nodes = [
{
id: 1,
},
{
id: 2,
},
{
id: 3,
isDeleted: true,
},
];
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
<VisualizerToolbar
onClose={close}
onSave={save}
template={template}
/>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Shows correct number of nodes', () => {
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
expect(wrapper.find('Badge').text()).toBe('1');
});
test('Toggle Legend button dispatches as expected', () => {
wrapper.find('CompassIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LEGEND' });
});
test('Toggle Tools button dispatches as expected', () => {
wrapper.find('WrenchIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_TOOLS' });
});
test('Delete All button dispatches as expected', () => {
wrapper.find('TrashAltIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
value: true,
});
});
test('Delete All button dispatches as expected', () => {
wrapper.find('TrashAltIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
value: true,
});
});
test('Save button calls expected function', () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
expect(save).toHaveBeenCalled();
});
test('Close button calls expected function', () => {
wrapper.find('TimesIcon').simulate('click');
expect(close).toHaveBeenCalled();
});
});