Move visualizer/workflow output state logic out to reducer and refactor some of the larger functions. Introduces contexts for state/dispatch that can be used by descendent components of both the visualizer and the workflow output components.

This commit is contained in:
mabashian
2020-01-28 12:14:13 -05:00
parent a786118415
commit 2bbcd2d663
44 changed files with 1373 additions and 1295 deletions

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { func } from 'prop-types';
import { import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
PauseIcon, PauseIcon,
@@ -77,12 +77,14 @@ const Close = styled(TimesIcon)`
top: 15px; top: 15px;
`; `;
function WorkflowLegend({ i18n, onClose }) { function WorkflowLegend({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return ( return (
<Wrapper> <Wrapper>
<Header> <Header>
<b>{i18n._(t`Legend`)}</b> <b>{i18n._(t`Legend`)}</b>
<Close onClick={onClose} /> <Close onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} />
</Header> </Header>
<Legend> <Legend>
<li> <li>
@@ -128,8 +130,4 @@ function WorkflowLegend({ i18n, onClose }) {
); );
} }
WorkflowLegend.propTypes = {
onClose: func.isRequired,
};
export default withI18n()(WorkflowLegend); export default withI18n()(WorkflowLegend);

View File

@@ -1,8 +1,12 @@
import React, { useRef, useState } from 'react'; import React, { useContext, useRef, useState } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import styled from 'styled-components'; import styled from 'styled-components';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { bool, func, shape } from 'prop-types'; import { bool, func } from 'prop-types';
import { PlusIcon } from '@patternfly/react-icons'; import { PlusIcon } from '@patternfly/react-icons';
import { constants as wfConstants } from '@components/Workflow/WorkflowUtils'; import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
import { import {
@@ -14,16 +18,11 @@ const StartG = styled.g`
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')}; pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
`; `;
function WorkflowStartNode({ function WorkflowStartNode({ i18n, onUpdateHelpText, showActionTooltip }) {
addingLink,
i18n,
nodePositions,
onAddNodeClick,
onUpdateHelpText,
showActionTooltip,
}) {
const ref = useRef(null); const ref = useRef(null);
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, nodePositions } = useContext(WorkflowStateContext);
const handleNodeMouseEnter = () => { const handleNodeMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current); ref.current.parentNode.appendChild(ref.current);
@@ -62,7 +61,7 @@ function WorkflowStartNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onAddNodeClick(1); dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 });
}} }}
> >
<PlusIcon /> <PlusIcon />
@@ -77,16 +76,11 @@ function WorkflowStartNode({
} }
WorkflowStartNode.propTypes = { WorkflowStartNode.propTypes = {
addingLink: bool,
nodePositions: shape().isRequired,
onAddNodeClick: func,
showActionTooltip: bool.isRequired, showActionTooltip: bool.isRequired,
onUpdateHelpText: func, onUpdateHelpText: func,
}; };
WorkflowStartNode.defaultProps = { WorkflowStartNode.defaultProps = {
addingLink: false,
onAddNodeClick: () => {},
onUpdateHelpText: () => {}, onUpdateHelpText: () => {},
}; };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { WorkflowStateContext } from '@contexts/Workflow';
import WorkflowStartNode from './WorkflowStartNode'; import WorkflowStartNode from './WorkflowStartNode';
const nodePositions = { const nodePositions = {
@@ -13,10 +14,12 @@ describe('WorkflowStartNode', () => {
test('mounts successfully', () => { test('mounts successfully', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowStartNode <WorkflowStateContext.Provider value={{ nodePositions }}>
nodePositions={nodePositions} <WorkflowStartNode
showActionTooltip={false} nodePositions={nodePositions}
/> showActionTooltip={false}
/>
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
@@ -24,7 +27,9 @@ describe('WorkflowStartNode', () => {
test('tooltip shown on hover', () => { test('tooltip shown on hover', () => {
const wrapper = mount( const wrapper = mount(
<svg> <svg>
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip /> <WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip />
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(0); expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(0);

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -53,13 +54,13 @@ const Close = styled(TimesIcon)`
function WorkflowTools({ function WorkflowTools({
i18n, i18n,
onClose,
onFitGraph, onFitGraph,
onPan, onPan,
onPanToMiddle, onPanToMiddle,
onZoomChange, onZoomChange,
zoomPercentage, zoomPercentage,
}) { }) {
const dispatch = useContext(WorkflowDispatchContext);
const zoomIn = () => { const zoomIn = () => {
const newScale = const newScale =
Math.ceil((zoomPercentage + 10) / 10) * 10 < 200 Math.ceil((zoomPercentage + 10) / 10) * 10 < 200
@@ -80,7 +81,7 @@ function WorkflowTools({
<Wrapper> <Wrapper>
<Header> <Header>
<b>{i18n._(t`Tools`)}</b> <b>{i18n._(t`Tools`)}</b>
<Close onClick={onClose} /> <Close onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} />
</Header> </Header>
<Tools> <Tools>
<Tooltip <Tooltip
@@ -178,7 +179,6 @@ function WorkflowTools({
} }
WorkflowTools.propTypes = { WorkflowTools.propTypes = {
onClose: func.isRequired,
onFitGraph: func.isRequired, onFitGraph: func.isRequired,
onPan: func.isRequired, onPan: func.isRequired,
onPanToMiddle: func.isRequired, onPanToMiddle: func.isRequired,

View File

@@ -0,0 +1,632 @@
import { t } from '@lingui/macro';
export default function visualizerReducer(state, action) {
switch (action.type) {
case 'CREATE_LINK':
return createLink(state, action.linkType);
case 'CREATE_NODE':
return createNode(state, action.node);
case 'CANCEL_LINK':
return cancelLink(state);
case 'CANCEL_LINK_MODAL':
return cancelLinkModal(state);
case 'CANCEL_NODE_MODAL':
return {
...state,
addNodeSource: null,
addNodeTarget: null,
nodeToEdit: null,
};
case 'DELETE_ALL_NODES':
return deleteAllNodes(state);
case 'DELETE_LINK':
return deleteLink(state);
case 'DELETE_NODE':
return deleteNode(state);
case 'GENERATE_NODES_AND_LINKS':
return generateNodesAndLinks(state, action.nodes, action.i18n);
case 'SELECT_SOURCE_FOR_LINKING':
return selectSourceForLinking(state, action.node);
case 'SET_ADD_LINK_SOURCE_NODE':
return { ...state, addLinkSourceNode: action.value };
case 'SET_ADD_LINK_TARGET_NODE':
return { ...state, addLinkTargetNode: action.value };
case 'SET_ADD_NODE_SOURCE':
return { ...state, addNodeSource: action.value };
case 'SET_ADD_NODE_TARGET':
return { ...state, addNodeTarget: action.value };
case 'SET_ADDING_LINK':
return { ...state, addingLink: action.value };
case 'SET_CONTENT_ERROR':
return { ...state, contentError: action.value };
case 'SET_IS_LOADING':
return { ...state, isLoading: action.value };
case 'SET_LINK_TO_DELETE':
return { ...state, linkToDelete: action.value };
case 'SET_LINK_TO_EDIT':
return { ...state, linkToEdit: action.value };
case 'SET_LINKS':
return { ...state, links: action.value };
case 'SET_NEXT_NODE_ID':
return { ...state, nextNodeId: action.value };
case 'SET_NODE_POSITIONS':
return { ...state, nodePositions: action.value };
case 'SET_NODE_TO_DELETE':
return { ...state, nodeToDelete: action.value };
case 'SET_NODE_TO_EDIT':
return { ...state, nodeToEdit: action.value };
case 'SET_NODE_TO_VIEW':
return { ...state, nodeToView: action.value };
case 'SET_NODES':
return { ...state, nodes: action.value };
case 'SET_SHOW_DELETE_ALL_NODES_MODAL':
return { ...state, showDeleteAllNodesModal: action.value };
case 'SET_SHOW_LEGEND':
return { ...state, showLegend: action.value };
case 'SET_SHOW_TOOLS':
return { ...state, showTools: action.value };
case 'SET_SHOW_UNSAVED_CHANGES_MODAL':
return { ...state, showUnsavedChangesModal: action.value };
case 'SET_UNSAVED_CHANGES':
return { ...state, unsavedChanges: action.value };
case 'START_ADD_NODE':
return {
...state,
addNodeSource: action.sourceNodeId,
addNodeTarget: action.targetNodeId || null,
};
case 'START_DELETE_LINK':
return startDeleteLink(state, action.link);
case 'TOGGLE_DELETE_ALL_NODES_MODAL':
return toggleDeleteAllNodesModal(state);
case 'TOGGLE_LEGEND':
return toggleLegend(state);
case 'TOGGLE_TOOLS':
return toggleTools(state);
case 'TOGGLE_UNSAVED_CHANGES_MODAL':
return toggleUnsavedChangesModal(state);
case 'UPDATE_LINK':
return updateLink(state, action.linkType);
case 'UPDATE_NODE':
return updateNode(state, action.node);
default:
throw new Error(`Unrecognized action type: ${action.type}`);
}
}
function createLink(state, linkType) {
const { addLinkSourceNode, addLinkTargetNode, links, nodes } = state;
const newLinks = [...links];
const newNodes = [...nodes];
newNodes.forEach(node => {
node.isInvalidLinkTarget = false;
});
newLinks.push({
source: { id: addLinkSourceNode.id },
target: { id: addLinkTargetNode.id },
linkType,
type: 'link',
});
newLinks.forEach((link, index) => {
if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) {
newLinks.splice(index, 1);
}
});
return {
...state,
addLinkSourceNode: null,
addLinkTargetNode: null,
addingLink: false,
linkToEdit: null,
links: newLinks,
nodes: newNodes,
unsavedChanges: true,
};
}
function createNode(state, node) {
const { addNodeSource, addNodeTarget, links, nodes, nextNodeId } = state;
const newNodes = [...nodes];
const newLinks = [...links];
newNodes.push({
id: nextNodeId,
type: 'node',
unifiedJobTemplate: node.nodeResource,
});
// Ensures that root nodes appear to always run
// after "START"
if (addNodeSource === 1) {
node.linkType = 'always';
}
newLinks.push({
source: { id: addNodeSource },
target: { id: nextNodeId },
linkType: node.linkType,
type: 'link',
});
if (addNodeTarget) {
newLinks.forEach(linkToCompare => {
if (
linkToCompare.source.id === addNodeSource &&
linkToCompare.target.id === addNodeTarget
) {
linkToCompare.source = { id: nextNodeId };
}
});
}
return {
...state,
addNodeSource: null,
addNodeTarget: null,
links: newLinks,
nextNodeId: nextNodeId + 1,
nodes: newNodes,
unsavedChanges: true,
};
}
function cancelLink(state) {
const { nodes } = state;
const newNodes = [...nodes];
newNodes.forEach(node => {
node.isInvalidLinkTarget = false;
});
return {
...state,
addLinkSourceNode: null,
addLinkTargetNode: null,
addingLink: false,
nodes: newNodes,
};
}
function cancelLinkModal(state) {
const { nodes } = state;
const newNodes = [...nodes];
newNodes.forEach(node => {
node.isInvalidLinkTarget = false;
});
return {
...state,
addLinkSourceNode: null,
addLinkTargetNode: null,
addingLink: false,
linkToEdit: null,
nodes: newNodes,
};
}
function deleteAllNodes(state) {
const { nodes } = state;
return {
...state,
addLinkSourceNode: null,
addLinkTargetNode: null,
addingLink: false,
links: [],
nodes: nodes.map(node => {
if (node.id !== 1) {
node.isDeleted = true;
}
return node;
}),
showDeleteAllNodesModal: false,
};
}
function deleteLink(state) {
const { links, linkToDelete } = state;
const newLinks = [...links];
for (let i = newLinks.length; i--; ) {
const link = newLinks[i];
if (
link.source.id === linkToDelete.source.id &&
link.target.id === linkToDelete.target.id
) {
newLinks.splice(i, 1);
}
}
if (!linkToDelete.isConvergenceLink) {
// Add a new link from the start node to the orphaned node
newLinks.push({
source: {
id: 1,
},
target: {
id: linkToDelete.target.id,
},
linkType: 'always',
type: 'link',
});
}
return {
...state,
links: newLinks,
linkToDelete: null,
unsavedChanges: true,
};
}
function addLinksFromParentsToChildren(
parents,
children,
newLinks,
linkParentMapping
) {
parents.forEach(parentId => {
children.forEach(child => {
if (parentId === 1) {
// We only want to create a link from the start node to this node if it
// doesn't have any other parents
if (linkParentMapping[child.id].length === 1) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
linkType: 'always',
type: 'link',
});
}
} else if (!linkParentMapping[child.id].includes(parentId)) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
linkType: child.linkType,
type: 'link',
});
}
});
});
}
function removeLinksFromDeletedNode(
nodeId,
newLinks,
linkParentMapping,
children,
parents
) {
for (let i = newLinks.length; i--; ) {
const link = newLinks[i];
if (!linkParentMapping[link.target.id]) {
linkParentMapping[link.target.id] = [];
}
linkParentMapping[link.target.id].push(link.source.id);
if (link.source.id === nodeId || link.target.id === nodeId) {
if (link.source.id === nodeId) {
children.push({ id: link.target.id, linkType: link.linkType });
} else if (link.target.id === nodeId) {
parents.push(link.source.id);
}
newLinks.splice(i, 1);
}
}
}
function deleteNode(state) {
const { links, nodes, nodeToDelete } = state;
const nodeId = nodeToDelete.id;
const newNodes = [...nodes];
const newLinks = [...links];
newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
// Update the links
const parents = [];
const children = [];
const linkParentMapping = {};
removeLinksFromDeletedNode(
nodeId,
newLinks,
linkParentMapping,
children,
parents
);
addLinksFromParentsToChildren(parents, children, newLinks, linkParentMapping);
return {
...state,
links: newLinks,
nodeToDelete: null,
nodes: newNodes,
unsavedChanges: true,
};
}
function generateNodes(workflowNodes, i18n) {
const allNodeIds = [];
const chartNodeIdToIndexMapping = {};
const nodeIdToChartNodeIdMapping = {};
let nodeIdCounter = 2;
const arrayOfNodesForChart = [
{
id: 1,
unifiedJobTemplate: {
name: i18n._(t`START`),
},
type: 'node',
},
];
workflowNodes.forEach(node => {
node.workflowMakerNodeId = nodeIdCounter;
const nodeObj = {
id: nodeIdCounter,
type: 'node',
originalNodeObject: node,
};
if (node.summary_fields.job) {
nodeObj.job = node.summary_fields.job;
}
if (node.summary_fields.unified_job_template) {
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
}
arrayOfNodesForChart.push(nodeObj);
allNodeIds.push(node.id);
nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
nodeIdCounter++;
});
return [
arrayOfNodesForChart,
allNodeIds,
nodeIdToChartNodeIdMapping,
chartNodeIdToIndexMapping,
nodeIdCounter,
];
}
function generateLinks(
workflowNodes,
chartNodeIdToIndexMapping,
nodeIdToChartNodeIdMapping,
arrayOfNodesForChart
) {
const arrayOfLinksForChart = [];
const nonRootNodeIds = [];
workflowNodes.forEach(node => {
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
node.success_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'success',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
node.failure_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'failure',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
node.always_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'always',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
});
return [arrayOfLinksForChart, nonRootNodeIds];
}
// TODO: check to make sure passing i18n into this reducer
// actually works the way we want it to. If not we may
// have to explore other options
function generateNodesAndLinks(state, workflowNodes, i18n) {
const [
arrayOfNodesForChart,
allNodeIds,
nodeIdToChartNodeIdMapping,
chartNodeIdToIndexMapping,
nodeIdCounter,
] = generateNodes(workflowNodes, i18n);
const [arrayOfLinksForChart, nonRootNodeIds] = generateLinks(
workflowNodes,
chartNodeIdToIndexMapping,
nodeIdToChartNodeIdMapping,
arrayOfNodesForChart
);
const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
const rootNodes = allNodeIds.filter(
nodeId => !uniqueNonRootNodeIds.includes(nodeId)
);
rootNodes.forEach(rootNodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[0],
target: arrayOfNodesForChart[targetIndex],
linkType: 'always',
type: 'link',
});
});
return {
...state,
links: arrayOfLinksForChart,
nodes: arrayOfNodesForChart,
nextNodeId: nodeIdCounter,
};
}
function selectSourceForLinking(state, sourceNode) {
const { links, nodes } = state;
const newNodes = [...nodes];
const parentMap = {};
const invalidLinkTargetIds = [];
// Find and mark any ancestors as disabled to prevent cycles
links.forEach(link => {
// id=1 is our artificial root node so we don't care about that
if (link.source.id === 1) {
return;
}
if (link.source.id === sourceNode.id) {
// Disables direct children from the add link process
invalidLinkTargetIds.push(link.target.id);
}
if (!parentMap[link.target.id]) {
parentMap[link.target.id] = [];
}
parentMap[link.target.id].push(link.source.id);
});
const getAncestors = id => {
if (parentMap[id]) {
parentMap[id].forEach(parentId => {
invalidLinkTargetIds.push(parentId);
getAncestors(parentId);
});
}
};
getAncestors(sourceNode.id);
// Filter out the duplicates
invalidLinkTargetIds
.filter((element, index, array) => index === array.indexOf(element))
.forEach(ancestorId => {
newNodes.forEach(node => {
if (node.id === ancestorId) {
node.isInvalidLinkTarget = true;
}
});
});
return {
...state,
addLinkSourceNode: sourceNode,
addingLink: true,
nodes: newNodes,
};
}
function startDeleteLink(state, link) {
const { links } = state;
const parentMap = {};
links.forEach(existingLink => {
if (!parentMap[existingLink.target.id]) {
parentMap[existingLink.target.id] = [];
}
parentMap[existingLink.target.id].push(existingLink.source.id);
});
link.isConvergenceLink = parentMap[link.target.id].length > 1;
return {
...state,
linkToDelete: link,
};
}
function toggleDeleteAllNodesModal(state) {
const { showDeleteAllNodesModal } = state;
return {
...state,
showDeleteAllNodesModal: !showDeleteAllNodesModal,
};
}
function toggleLegend(state) {
const { showLegend } = state;
return {
...state,
showLegend: !showLegend,
};
}
function toggleTools(state) {
const { showTools } = state;
return {
...state,
showTools: !showTools,
};
}
function toggleUnsavedChangesModal(state) {
const { showUnsavedChangesModal } = state;
return {
...state,
showUnsavedChangesModal: !showUnsavedChangesModal,
};
}
function updateLink(state, linkType) {
const { linkToEdit, links } = state;
const newLinks = [...links];
newLinks.forEach(link => {
if (
link.source.id === linkToEdit.source.id &&
link.target.id === linkToEdit.target.id
) {
link.linkType = linkType;
}
});
return {
...state,
linkToEdit: null,
links: newLinks,
unsavedChanges: true,
};
}
function updateNode(state, editedNode) {
const { nodeToEdit, nodes } = state;
const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
matchingNode.isEdited = true;
return {
...state,
nodeToEdit: null,
nodes: newNodes,
unsavedChanges: true,
};
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
// eslint-disable-next-line import/prefer-default-export
export const WorkflowDispatchContext = React.createContext(null);
export const WorkflowStateContext = React.createContext(null);

View File

@@ -1,12 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useReducer } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { shape } from 'prop-types'; import { shape } from 'prop-types';
import { CardBody as PFCardBody } from '@patternfly/react-core'; import { CardBody as PFCardBody } from '@patternfly/react-core';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { layoutGraph } from '@components/Workflow/WorkflowUtils'; import { layoutGraph } from '@components/Workflow/WorkflowUtils';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import workflowReducer from '@components/Workflow/workflowReducer';
import { WorkflowJobsAPI } from '@api'; import { WorkflowJobsAPI } from '@api';
import WorkflowOutputGraph from './WorkflowOutputGraph'; import WorkflowOutputGraph from './WorkflowOutputGraph';
import WorkflowOutputToolbar from './WorkflowOutputToolbar'; import WorkflowOutputToolbar from './WorkflowOutputToolbar';
@@ -36,148 +40,50 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
}; };
function WorkflowOutput({ job, i18n }) { function WorkflowOutput({ job, i18n }) {
const [contentError, setContentError] = useState(null); const [state, dispatch] = useReducer(workflowReducer, {
const [graphLinks, setGraphLinks] = useState([]); contentError: null,
const [graphNodes, setGraphNodes] = useState([]); isLoading: true,
const [isLoading, setIsLoading] = useState(true); links: [],
const [nodePositions, setNodePositions] = useState(null); nextNodeId: 0,
const [showLegend, setShowLegend] = useState(false); nodePositions: null,
const [showTools, setShowTools] = useState(false); nodes: [],
showLegend: false,
showTools: false,
});
const { contentError, isLoading, links, nodePositions, nodes } = state;
useEffect(() => { useEffect(() => {
const buildGraphArrays = nodes => {
const allNodeIds = [];
const arrayOfLinksForChart = [];
const chartNodeIdToIndexMapping = {};
const nodeIdToChartNodeIdMapping = {};
const nodeRef = {};
const nonRootNodeIds = [];
let nodeIdCounter = 1;
const arrayOfNodesForChart = [
{
id: nodeIdCounter,
unifiedJobTemplate: {
name: i18n._(t`START`),
},
type: 'node',
},
];
nodeIdCounter++;
// Assign each node an ID - 0 is reserved for the start node. We need to
// make sure that we have an ID on every node including new nodes so the
// ID returned by the api won't do
nodes.forEach(node => {
node.workflowMakerNodeId = nodeIdCounter;
nodeRef[nodeIdCounter] = {
originalNodeObject: node,
};
const nodeObj = {
index: nodeIdCounter - 1,
id: nodeIdCounter,
type: 'node',
};
if (node.summary_fields.job) {
nodeObj.job = node.summary_fields.job;
}
if (node.summary_fields.unified_job_template) {
nodeRef[nodeIdCounter].unifiedJobTemplate =
node.summary_fields.unified_job_template;
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
}
arrayOfNodesForChart.push(nodeObj);
allNodeIds.push(node.id);
nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
nodeIdCounter++;
});
nodes.forEach(node => {
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
node.success_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'success',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
node.failure_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'failure',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
node.always_nodes.forEach(nodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
linkType: 'always',
type: 'link',
});
nonRootNodeIds.push(nodeId);
});
});
const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
const rootNodes = allNodeIds.filter(
nodeId => !uniqueNonRootNodeIds.includes(nodeId)
);
rootNodes.forEach(rootNodeId => {
const targetIndex =
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[0],
target: arrayOfNodesForChart[targetIndex],
linkType: 'always',
type: 'link',
});
});
setGraphNodes(arrayOfNodesForChart);
setGraphLinks(arrayOfLinksForChart);
};
async function fetchData() { async function fetchData() {
try { try {
const nodes = await fetchWorkflowNodes(job.id); const workflowNodes = await fetchWorkflowNodes(job.id);
buildGraphArrays(nodes); dispatch({
type: 'GENERATE_NODES_AND_LINKS',
nodes: workflowNodes,
i18n,
});
} catch (error) { } catch (error) {
setContentError(error); dispatch({ type: 'SET_CONTENT_ERROR', value: error });
} finally { } finally {
setIsLoading(false); dispatch({ type: 'SET_IS_LOADING', value: false });
} }
} }
fetchData(); fetchData();
}, [job.id, job.unified_job_template, i18n]); }, [job.id, i18n]);
// Update positions of nodes/links // Update positions of nodes/links
useEffect(() => { useEffect(() => {
if (graphNodes) { if (nodes) {
const newNodePositions = {}; const newNodePositions = {};
const g = layoutGraph(graphNodes, graphLinks); const g = layoutGraph(nodes, links);
g.nodes().forEach(node => { g.nodes().forEach(node => {
newNodePositions[node] = g.node(node); newNodePositions[node] = g.node(node);
}); });
setNodePositions(newNodePositions); dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
} }
}, [graphLinks, graphNodes]); }, [links, nodes]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -196,29 +102,16 @@ function WorkflowOutput({ job, i18n }) {
} }
return ( return (
<CardBody> <WorkflowStateContext.Provider value={state}>
<Wrapper> <WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowOutputToolbar <CardBody>
job={job} <Wrapper>
legendShown={showLegend} <WorkflowOutputToolbar job={job} />
nodes={graphNodes} {nodePositions && <WorkflowOutputGraph />}
onLegendToggle={() => setShowLegend(!showLegend)} </Wrapper>
onToolsToggle={() => setShowTools(!showTools)} </CardBody>
toolsShown={showTools} </WorkflowDispatchContext.Provider>
/> </WorkflowStateContext.Provider>
{nodePositions && (
<WorkflowOutputGraph
links={graphLinks}
nodePositions={nodePositions}
nodes={graphNodes}
onUpdateShowLegend={setShowLegend}
onUpdateShowTools={setShowTools}
showLegend={showLegend}
showTools={showTools}
/>
)}
</Wrapper>
</CardBody>
); );
} }

View File

@@ -1,6 +1,6 @@
import React, { Fragment, useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { arrayOf, bool, shape, func } from 'prop-types';
import { import {
getScaleAndOffsetToFit, getScaleAndOffsetToFit,
getTranslatePointsForZoom, getTranslatePointsForZoom,
@@ -18,21 +18,17 @@ import {
WorkflowTools, WorkflowTools,
} from '@components/Workflow'; } from '@components/Workflow';
function WorkflowOutputGraph({ function WorkflowOutputGraph() {
links,
nodePositions,
nodes,
onUpdateShowLegend,
onUpdateShowTools,
showLegend,
showTools,
}) {
const [linkHelp, setLinkHelp] = useState(); 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);
const gRef = useRef(null); const gRef = useRef(null);
const { links, nodePositions, nodes, showLegend, showTools } = useContext(
WorkflowStateContext
);
// This is the zoom function called by using the mousewheel/click and drag // This is the zoom function called by using the mousewheel/click and drag
const zoom = () => { const zoom = () => {
const translation = [d3.event.transform.x, d3.event.transform.y]; const translation = [d3.event.transform.x, d3.event.transform.y];
@@ -158,7 +154,7 @@ function WorkflowOutputGraph({
}, []); }, []);
return ( return (
<Fragment> <>
{(nodeHelp || linkHelp) && ( {(nodeHelp || linkHelp) && (
<WorkflowHelp> <WorkflowHelp>
{nodeHelp && <WorkflowNodeHelp node={nodeHelp} />} {nodeHelp && <WorkflowNodeHelp node={nodeHelp} />}
@@ -172,16 +168,11 @@ function WorkflowOutputGraph({
> >
<g id="workflow-g" ref={gRef}> <g id="workflow-g" ref={gRef}>
{nodePositions && [ {nodePositions && [
<WorkflowStartNode <WorkflowStartNode key="start" showActionTooltip={false} />,
key="start"
nodePositions={nodePositions}
showActionTooltip={false}
/>,
links.map(link => ( links.map(link => (
<WorkflowOutputLink <WorkflowOutputLink
key={`link-${link.source.id}-${link.target.id}`} key={`link-${link.source.id}-${link.target.id}`}
link={link} link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={setLinkHelp} onUpdateLinkHelp={setLinkHelp}
/> />
)), )),
@@ -193,7 +184,6 @@ function WorkflowOutputGraph({
mouseEnter={() => setNodeHelp(node)} mouseEnter={() => setNodeHelp(node)}
mouseLeave={() => setNodeHelp(null)} mouseLeave={() => setNodeHelp(null)}
node={node} node={node}
nodePositions={nodePositions}
/> />
); );
} }
@@ -205,7 +195,6 @@ function WorkflowOutputGraph({
<div css="position: absolute; top: 75px;right: 20px;display: flex;"> <div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && ( {showTools && (
<WorkflowTools <WorkflowTools
onClose={() => onUpdateShowTools(false)}
onFitGraph={handleFitGraph} onFitGraph={handleFitGraph}
onPan={handlePan} onPan={handlePan}
onPanToMiddle={handlePanToMiddle} onPanToMiddle={handlePanToMiddle}
@@ -213,22 +202,10 @@ function WorkflowOutputGraph({
zoomPercentage={zoomPercentage} zoomPercentage={zoomPercentage}
/> />
)} )}
{showLegend && ( {showLegend && <WorkflowLegend />}
<WorkflowLegend onClose={() => onUpdateShowLegend(false)} />
)}
</div> </div>
</Fragment> </>
); );
} }
WorkflowOutputGraph.propTypes = {
links: arrayOf(shape()).isRequired,
nodePositions: shape().isRequired,
nodes: arrayOf(shape()).isRequired,
onUpdateShowLegend: func.isRequired,
onUpdateShowTools: func.isRequired,
showLegend: bool.isRequired,
showTools: bool.isRequired,
};
export default WorkflowOutputGraph; export default WorkflowOutputGraph;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { shape } from 'prop-types'; import { shape } from 'prop-types';
import { import {
generateLine, generateLine,
@@ -6,11 +7,12 @@ import {
getLinkOverlayPoints, getLinkOverlayPoints,
} from '@components/Workflow/WorkflowUtils'; } from '@components/Workflow/WorkflowUtils';
function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { function WorkflowOutputLink({ link, onUpdateLinkHelp }) {
const ref = useRef(null); const ref = useRef(null);
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState(); const [pathD, setPathD] = useState();
const [pathStroke, setPathStroke] = useState('#CCCCCC'); const [pathStroke, setPathStroke] = useState('#CCCCCC');
const { nodePositions } = useContext(WorkflowStateContext);
const handleLinkMouseEnter = () => { const handleLinkMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current); ref.current.parentNode.appendChild(ref.current);
@@ -65,7 +67,6 @@ function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) {
WorkflowOutputLink.propTypes = { WorkflowOutputLink.propTypes = {
link: shape().isRequired, link: shape().isRequired,
nodePositions: shape().isRequired,
}; };
export default WorkflowOutputLink; export default WorkflowOutputLink;

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mount } from 'enzyme';
import { WorkflowStateContext } from '@contexts/Workflow';
import WorkflowOutputLink from './WorkflowOutputLink'; import WorkflowOutputLink from './WorkflowOutputLink';
const link = { const link = {
@@ -28,13 +29,15 @@ const nodePositions = {
describe('WorkflowOutputLink', () => { describe('WorkflowOutputLink', () => {
test('mounts successfully', () => { test('mounts successfully', () => {
const wrapper = mountWithContexts( const wrapper = mount(
<svg> <svg>
<WorkflowOutputLink <WorkflowStateContext.Provider value={{ nodePositions }}>
link={link} <WorkflowOutputLink
nodePositions={nodePositions} link={link}
onUpdateLinkHelp={() => {}} nodePositions={nodePositions}
/> onUpdateLinkHelp={() => {}}
/>
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);

View File

@@ -1,4 +1,5 @@
import React, { Fragment } from 'react'; import React, { useContext } from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -55,14 +56,8 @@ const NodeDefaultLabel = styled.p`
white-space: nowrap; white-space: nowrap;
`; `;
function WorkflowOutputNode({ function WorkflowOutputNode({ history, i18n, mouseEnter, mouseLeave, node }) {
history, const { nodePositions } = useContext(WorkflowStateContext);
i18n,
mouseEnter,
mouseLeave,
node,
nodePositions,
}) {
let borderColor = '#93969A'; let borderColor = '#93969A';
if (node.job) { if (node.job) {
@@ -105,7 +100,7 @@ function WorkflowOutputNode({
/> />
<NodeContents height="60" width="180"> <NodeContents height="60" width="180">
{node.job ? ( {node.job ? (
<Fragment> <>
<JobTopLine> <JobTopLine>
<StatusIcon status={node.job.status} /> <StatusIcon status={node.job.status} />
<p> <p>
@@ -115,7 +110,7 @@ function WorkflowOutputNode({
</p> </p>
</JobTopLine> </JobTopLine>
<Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed> <Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>
</Fragment> </>
) : ( ) : (
<NodeDefaultLabel> <NodeDefaultLabel>
{node.unifiedJobTemplate {node.unifiedJobTemplate
@@ -134,7 +129,6 @@ WorkflowOutputNode.propTypes = {
mouseEnter: func.isRequired, mouseEnter: func.isRequired,
mouseLeave: func.isRequired, mouseLeave: func.isRequired,
node: shape().isRequired, node: shape().isRequired,
nodePositions: shape().isRequired,
}; };
export default withI18n()(withRouter(WorkflowOutputNode)); export default withI18n()(withRouter(WorkflowOutputNode));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputNode from './WorkflowOutputNode'; import WorkflowOutputNode from './WorkflowOutputNode';
@@ -48,12 +49,13 @@ describe('WorkflowOutputNode', () => {
test('mounts successfully', () => { test('mounts successfully', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<svg> <svg>
<WorkflowOutputNode <WorkflowStateContext.Provider value={{ nodePositions }}>
mouseEnter={() => {}} <WorkflowOutputNode
mouseLeave={() => {}} mouseEnter={() => {}}
node={nodeWithJT} mouseLeave={() => {}}
nodePositions={nodePositions} node={nodeWithJT}
/> />
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
@@ -61,12 +63,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job and Job Template exist', () => { test('node contents displayed correctly when Job and Job Template exist', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<svg> <svg>
<WorkflowOutputNode <WorkflowStateContext.Provider value={{ nodePositions }}>
mouseEnter={() => {}} <WorkflowOutputNode
mouseLeave={() => {}} mouseEnter={() => {}}
node={nodeWithJT} mouseLeave={() => {}}
nodePositions={nodePositions} node={nodeWithJT}
/> />
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true); expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true);
@@ -75,12 +78,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job Template deleted', () => { test('node contents displayed correctly when Job Template deleted', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<svg> <svg>
<WorkflowOutputNode <WorkflowStateContext.Provider value={{ nodePositions }}>
mouseEnter={() => {}} <WorkflowOutputNode
mouseLeave={() => {}} mouseEnter={() => {}}
node={nodeWithoutJT} mouseLeave={() => {}}
nodePositions={nodePositions} node={nodeWithoutJT}
/> />
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.contains(<p>DELETED</p>)).toEqual(true); expect(wrapper.contains(<p>DELETED</p>)).toEqual(true);
@@ -89,12 +93,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job deleted', () => { test('node contents displayed correctly when Job deleted', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<svg> <svg>
<WorkflowOutputNode <WorkflowStateContext.Provider value={{ nodePositions }}>
mouseEnter={() => {}} <WorkflowOutputNode
mouseLeave={() => {}} mouseEnter={() => {}}
node={{ id: 2 }} mouseLeave={() => {}}
nodePositions={nodePositions} node={{ id: 2 }}
/> />
</WorkflowStateContext.Provider>
</svg> </svg>
); );
expect(wrapper.text()).toBe('DELETED'); expect(wrapper.text()).toBe('DELETED');

View File

@@ -1,7 +1,11 @@
import React from 'react'; import React, { useContext } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { arrayOf, bool, func, shape } from 'prop-types'; import { shape } from 'prop-types';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core'; import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import { CompassIcon, WrenchIcon } from '@patternfly/react-icons'; import { CompassIcon, WrenchIcon } from '@patternfly/react-icons';
import { StatusIcon } from '@components/Sparkline'; import { StatusIcon } from '@components/Sparkline';
@@ -53,15 +57,11 @@ const StatusIconWithMargin = styled(StatusIcon)`
margin-right: 20px; margin-right: 20px;
`; `;
function WorkflowOutputToolbar({ function WorkflowOutputToolbar({ i18n, job }) {
i18n, const dispatch = useContext(WorkflowDispatchContext);
job,
legendShown, const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
nodes,
onLegendToggle,
onToolsToggle,
toolsShown,
}) {
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return ( return (
@@ -76,8 +76,8 @@ function WorkflowOutputToolbar({
<VerticalSeparator /> <VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
<ActionButton <ActionButton
isActive={legendShown} isActive={showLegend}
onClick={onLegendToggle} onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain" variant="plain"
> >
<CompassIcon /> <CompassIcon />
@@ -85,8 +85,8 @@ function WorkflowOutputToolbar({
</Tooltip> </Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom"> <Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton <ActionButton
isActive={toolsShown} isActive={showTools}
onClick={onToolsToggle} onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain" variant="plain"
> >
<WrenchIcon /> <WrenchIcon />
@@ -99,15 +99,6 @@ function WorkflowOutputToolbar({
WorkflowOutputToolbar.propTypes = { WorkflowOutputToolbar.propTypes = {
job: shape().isRequired, job: shape().isRequired,
legendShown: bool.isRequired,
nodes: arrayOf(shape()),
onLegendToggle: func.isRequired,
onToolsToggle: func.isRequired,
toolsShown: bool.isRequired,
};
WorkflowOutputToolbar.defaultProps = {
nodes: [],
}; };
export default withI18n()(WorkflowOutputToolbar); export default withI18n()(WorkflowOutputToolbar);

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputToolbar from './WorkflowOutputToolbar'; import WorkflowOutputToolbar from './WorkflowOutputToolbar';
@@ -7,17 +8,18 @@ const job = {
status: 'successful', status: 'successful',
}; };
const workflowContext = {
nodes: [],
showLegend: false,
showTools: false,
};
describe('WorkflowOutputToolbar', () => { describe('WorkflowOutputToolbar', () => {
test('mounts successfully', () => { test('mounts successfully', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<WorkflowOutputToolbar <WorkflowStateContext.Provider value={workflowContext}>
job={job} <WorkflowOutputToolbar job={job} />
legendShown={false} </WorkflowStateContext.Provider>
nodes={[]}
onLegendToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
@@ -36,14 +38,9 @@ describe('WorkflowOutputToolbar', () => {
}, },
]; ];
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<WorkflowOutputToolbar <WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
job={job} <WorkflowOutputToolbar job={job} />
legendShown={false} </WorkflowStateContext.Provider>
nodes={nodes}
onLegendToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
); );
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
expect(wrapper.find('Badge').text()).toBe('1'); expect(wrapper.find('Badge').text()).toBe('1');

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func } from 'prop-types';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { function DeleteAllNodesModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return ( return (
<AlertModal <AlertModal
actions={[ actions={[
@@ -13,7 +14,7 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
key="remove" key="remove"
variant="danger" variant="danger"
aria-label={i18n._(t`Confirm removal of all nodes`)} aria-label={i18n._(t`Confirm removal of all nodes`)}
onClick={() => onConfirm()} onClick={() => dispatch({ type: 'DELETE_ALL_NODES' })}
> >
{i18n._(t`Remove`)} {i18n._(t`Remove`)}
</Button>, </Button>,
@@ -21,13 +22,13 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
key="cancel" key="cancel"
variant="secondary" variant="secondary"
aria-label={i18n._(t`Cancel node removal`)} aria-label={i18n._(t`Cancel node removal`)}
onClick={onCancel} onClick={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
</Button>, </Button>,
]} ]}
isOpen isOpen
onClose={onCancel} onClose={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
title={i18n._(t`Remove All Nodes`)} title={i18n._(t`Remove All Nodes`)}
variant="danger" variant="danger"
> >
@@ -40,9 +41,4 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
); );
} }
DeleteAllNodesModal.propTypes = {
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(DeleteAllNodesModal); export default withI18n()(DeleteAllNodesModal);

View File

@@ -0,0 +1,22 @@
import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import LinkModal from './LinkModal';
function LinkAddModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<LinkModal
header={
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
{i18n._(t`Add Link`)}
</Title>
}
onConfirm={linkType => dispatch({ type: 'CREATE_LINK', linkType })}
/>
);
}
export default withI18n()(LinkAddModal);

View File

@@ -1,22 +1,27 @@
import React, { Fragment } from 'react'; import React, { Fragment, useContext } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { function LinkDeleteModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { linkToDelete } = useContext(WorkflowStateContext);
return ( return (
<AlertModal <AlertModal
variant="danger" variant="danger"
title="Remove Link" title="Remove Link"
isOpen={linkToDelete} isOpen={linkToDelete}
onClose={onCancel} onClose={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
actions={[ actions={[
<Button <Button
aria-label={i18n._(t`Confirm link removal`)} aria-label={i18n._(t`Confirm link removal`)}
key="remove" key="remove"
onClick={() => onConfirm()} onClick={() => dispatch({ type: 'DELETE_LINK' })}
variant="danger" variant="danger"
> >
{i18n._(t`Remove`)} {i18n._(t`Remove`)}
@@ -24,7 +29,7 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
<Button <Button
aria-label={i18n._(t`Cancel link removal`)} aria-label={i18n._(t`Cancel link removal`)}
key="cancel" key="cancel"
onClick={onCancel} onClick={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
variant="secondary" variant="secondary"
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
@@ -46,10 +51,4 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
); );
} }
LinkDeleteModal.propTypes = {
linkToDelete: shape().isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(LinkDeleteModal); export default withI18n()(LinkDeleteModal);

View File

@@ -0,0 +1,22 @@
import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import LinkModal from './LinkModal';
function LinkEditModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<LinkModal
header={
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
{i18n._(t`Edit Link`)}
</Title>
}
onConfirm={linkType => dispatch({ type: 'UPDATE_LINK', linkType })}
/>
);
}
export default withI18n()(LinkEditModal);

View File

@@ -1,25 +1,33 @@
import React, { useState } from 'react'; import React, { useContext, useState } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { Button, FormGroup, Modal } from '@patternfly/react-core'; import { Button, FormGroup, Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, node, string } from 'prop-types'; import { func } from 'prop-types';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) { function LinkModal({ header, i18n, onConfirm }) {
const [newLinkType, setNewLinkType] = useState(linkType); const dispatch = useContext(WorkflowDispatchContext);
const { linkToEdit } = useContext(WorkflowStateContext);
const [linkType, setLinkType] = useState(
linkToEdit ? linkToEdit.linkType : 'success'
);
return ( return (
<Modal <Modal
width={600} width={600}
header={header} header={header}
isOpen isOpen
title={i18n._(t`Workflow Link`)} title={i18n._(t`Workflow Link`)}
onClose={onCancel} onClose={() => dispatch({ type: 'CANCEL_LINK_MODAL' })}
actions={[ actions={[
<Button <Button
key="save" key="save"
variant="primary" variant="primary"
aria-label={i18n._(t`Save link changes`)} aria-label={i18n._(t`Save link changes`)}
onClick={() => onConfirm(newLinkType)} onClick={() => onConfirm(linkType)}
> >
{i18n._(t`Save`)} {i18n._(t`Save`)}
</Button>, </Button>,
@@ -27,7 +35,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) {
key="cancel" key="cancel"
variant="secondary" variant="secondary"
aria-label={i18n._(t`Cancel link changes`)} aria-label={i18n._(t`Cancel link changes`)}
onClick={onCancel} onClick={() => dispatch({ type: 'CANCEL_LINK_MODAL' })}
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
</Button>, </Button>,
@@ -36,7 +44,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) {
<FormGroup fieldId="link-select" label={i18n._(t`Run`)}> <FormGroup fieldId="link-select" label={i18n._(t`Run`)}>
<AnsibleSelect <AnsibleSelect
id="link-select" id="link-select"
value={newLinkType} value={linkType}
data={[ data={[
{ {
value: 'always', value: 'always',
@@ -55,7 +63,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) {
}, },
]} ]}
onChange={(event, value) => { onChange={(event, value) => {
setNewLinkType(value); setLinkType(value);
}} }}
/> />
</FormGroup> </FormGroup>
@@ -64,14 +72,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) {
} }
LinkModal.propTypes = { LinkModal.propTypes = {
linkType: string,
header: node.isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired, onConfirm: func.isRequired,
}; };
LinkModal.defaultProps = {
linkType: 'success',
};
export default withI18n()(LinkModal); export default withI18n()(LinkModal);

View File

@@ -0,0 +1,4 @@
export { default as LinkDeleteModal } from './LinkDeleteModal';
export { default as LinkAddModal } from './LinkAddModal';
export { default as LinkEditModal } from './LinkEditModal';
export { default as LinkModal } from './LinkModal';

View File

@@ -1,3 +0,0 @@
export { default as NodeModal } from './NodeModal';
export { default as NodeNextButton } from './NodeNextButton';
export { default as RunStep } from './RunStep';

View File

@@ -0,0 +1,34 @@
import React, { useContext } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import NodeModal from './NodeModal';
function NodeAddModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { addNodeSource } = useContext(WorkflowStateContext);
const addNode = (linkType, resource, nodeType) => {
dispatch({
type: 'CREATE_NODE',
node: {
linkType,
nodeResource: resource,
nodeType,
},
});
};
return (
<NodeModal
askLinkType={addNodeSource !== 1}
onSave={addNode}
title={i18n._(t`Add Node`)}
/>
);
}
export default withI18n()(NodeAddModal);

View File

@@ -1,23 +1,28 @@
import React, { Fragment } from 'react'; import React, { Fragment, useContext } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { function NodeDeleteModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToDelete } = useContext(WorkflowStateContext);
return ( return (
<AlertModal <AlertModal
variant="danger" variant="danger"
title={i18n._(t`Remove Node`)} title={i18n._(t`Remove Node`)}
isOpen={nodeToDelete} isOpen={nodeToDelete}
onClose={onCancel} onClose={() => dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
actions={[ actions={[
<Button <Button
key="remove" key="remove"
variant="danger" variant="danger"
aria-label={i18n._(t`Confirm node removal`)} aria-label={i18n._(t`Confirm node removal`)}
onClick={() => onConfirm()} onClick={() => dispatch({ type: 'DELETE_NODE' })}
> >
{i18n._(t`Remove`)} {i18n._(t`Remove`)}
</Button>, </Button>,
@@ -25,7 +30,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
key="cancel" key="cancel"
variant="secondary" variant="secondary"
aria-label={i18n._(t`Cancel node removal`)} aria-label={i18n._(t`Cancel node removal`)}
onClick={onCancel} onClick={() => dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
> >
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
</Button>, </Button>,
@@ -46,10 +51,4 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
); );
} }
NodeDeleteModal.propTypes = {
nodeToDelete: shape().isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(NodeDeleteModal); export default withI18n()(NodeDeleteModal);

View File

@@ -0,0 +1,30 @@
import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import NodeModal from './NodeModal';
function NodeEditModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const updateNode = (linkType, resource, nodeType) => {
dispatch({
type: 'UPDATE_NODE',
node: {
linkType,
nodeResource: resource,
nodeType,
},
});
};
return (
<NodeModal
askLinkType={false}
onSave={updateNode}
title={i18n._(t`Edit Node`)}
/>
);
}
export default withI18n()(NodeEditModal);

View File

@@ -1,8 +1,12 @@
import React, { useState } from 'react'; import React, { useContext, useState } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { bool, func, node, shape } from 'prop-types'; import { bool, node, func } from 'prop-types';
import { import {
Button, Button,
WizardContextConsumer, WizardContextConsumer,
@@ -12,15 +16,10 @@ import Wizard from '@components/Wizard';
import { NodeTypeStep } from './NodeTypeStep'; import { NodeTypeStep } from './NodeTypeStep';
import { RunStep, NodeNextButton } from '.'; import { RunStep, NodeNextButton } from '.';
function NodeModal({ function NodeModal({ askLinkType, history, i18n, onSave, title }) {
askLinkType, const dispatch = useContext(WorkflowDispatchContext);
history, const { nodeToEdit } = useContext(WorkflowStateContext);
i18n,
nodeToEdit,
onClose,
onSave,
title,
}) {
let defaultApprovalDescription = ''; let defaultApprovalDescription = '';
let defaultApprovalName = ''; let defaultApprovalName = '';
let defaultApprovalTimeout = 0; let defaultApprovalTimeout = 0;
@@ -104,16 +103,12 @@ function NodeModal({
} }
: nodeResource; : nodeResource;
onSave({ onSave(linkType, resource, nodeType);
linkType,
nodeResource: resource,
nodeType,
});
}; };
const handleCancel = () => { const handleCancel = () => {
clearQueryParams(); clearQueryParams();
onClose(); dispatch({ type: 'CANCEL_NODE_MODAL' });
}; };
const handleNodeTypeChange = newNodeType => { const handleNodeTypeChange = newNodeType => {
@@ -211,14 +206,8 @@ function NodeModal({
NodeModal.propTypes = { NodeModal.propTypes = {
askLinkType: bool.isRequired, askLinkType: bool.isRequired,
nodeToEdit: shape(),
onClose: func.isRequired,
onSave: func.isRequired, onSave: func.isRequired,
title: node.isRequired, title: node.isRequired,
}; };
NodeModal.defaultProps = {
nodeToEdit: null,
};
export default withI18n()(withRouter(NodeModal)); export default withI18n()(withRouter(NodeModal));

View File

@@ -0,0 +1,21 @@
import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
function NodeViewModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<Modal
isLarge
isOpen
title={i18n._(t`Node Details`)}
onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
>
Coming soon :)
</Modal>
);
}
export default withI18n()(NodeViewModal);

View File

@@ -0,0 +1,7 @@
export { default as NodeAddModal } from './NodeAddModal';
export { default as NodeDeleteModal } from './NodeDeleteModal';
export { default as NodeEditModal } from './NodeEditModal';
export { default as NodeModal } from './NodeModal';
export { default as NodeNextButton } from './NodeNextButton';
export { default as NodeViewModal } from './NodeViewModal';
export { default as RunStep } from './RunStep';

View File

@@ -1,19 +0,0 @@
import React from 'react';
import { Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func } from 'prop-types';
function NodeViewModal({ i18n, onClose }) {
return (
<Modal isLarge isOpen title={i18n._(t`Node Details`)} onClose={onClose}>
Coming soon :)
</Modal>
);
}
NodeViewModal.propTypes = {
onClose: func.isRequired,
};
export default withI18n()(NodeViewModal);

View File

@@ -1,16 +1,18 @@
import React from 'react'; import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { Button, Modal } from '@patternfly/react-core'; import { Button, Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { func } from 'prop-types'; import { func } from 'prop-types';
function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) { function UnsavedChangesModal({ i18n, onSaveAndExit, onExit }) {
const dispatch = useContext(WorkflowDispatchContext);
return ( return (
<Modal <Modal
width={600} width={600}
isOpen isOpen
title={i18n._(t`Warning: Unsaved Changes`)} title={i18n._(t`Warning: Unsaved Changes`)}
onClose={onCancel} onClose={() => dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' })}
actions={[ actions={[
<Button <Button
key="exit" key="exit"
@@ -41,7 +43,6 @@ function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
} }
UnsavedChangesModal.propTypes = { UnsavedChangesModal.propTypes = {
onCancel: func.isRequired,
onExit: func.isRequired, onExit: func.isRequired,
onSaveAndExit: func.isRequired, onSaveAndExit: func.isRequired,
}; };

View File

@@ -1,6 +1,2 @@
export { default as DeleteAllNodesModal } from './DeleteAllNodesModal'; export { default as DeleteAllNodesModal } from './DeleteAllNodesModal';
export { default as LinkDeleteModal } from './LinkDeleteModal';
export { default as LinkModal } from './LinkModal';
export { default as NodeDeleteModal } from './NodeDeleteModal';
export { default as NodeViewModal } from './NodeViewModal';
export { default as UnsavedChangesModal } from './UnsavedChangesModal'; export { default as UnsavedChangesModal } from './UnsavedChangesModal';

View File

@@ -1,8 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { arrayOf, bool, func, shape } from 'prop-types'; import { bool } from 'prop-types';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { import {
getScaleAndOffsetToFit, getScaleAndOffsetToFit,
@@ -32,28 +36,7 @@ const WorkflowSVG = styled.svg`
height: 100%; height: 100%;
`; `;
function VisualizerGraph({ function VisualizerGraph({ i18n, readOnly }) {
addLinkSourceNode,
addingLink,
i18n,
links,
nodePositions,
nodes,
onAddNodeClick,
onCancelAddLinkClick,
onConfirmAddLinkClick,
onDeleteLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onLinkEditClick,
onStartAddLinkClick,
onUpdateShowLegend,
onUpdateShowTools,
onViewNodeClick,
readOnly,
showLegend,
showTools,
}) {
const [helpText, setHelpText] = useState(null); const [helpText, setHelpText] = useState(null);
const [linkHelp, setLinkHelp] = useState(); const [linkHelp, setLinkHelp] = useState();
const [nodeHelp, setNodeHelp] = useState(); const [nodeHelp, setNodeHelp] = useState();
@@ -61,6 +44,18 @@ function VisualizerGraph({
const svgRef = useRef(null); const svgRef = useRef(null);
const gRef = useRef(null); const gRef = useRef(null);
const {
addLinkSourceNode,
addingLink,
links,
nodePositions,
nodes,
showLegend,
showTools,
} = useContext(WorkflowStateContext);
const dispatch = useContext(WorkflowDispatchContext);
const drawPotentialLinkToNode = node => { const drawPotentialLinkToNode = node => {
if (node.id !== addLinkSourceNode.id) { if (node.id !== addLinkSourceNode.id) {
const sourceNodeX = nodePositions[addLinkSourceNode.id].x; const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
@@ -81,7 +76,7 @@ function VisualizerGraph({
const handleBackgroundClick = () => { const handleBackgroundClick = () => {
setHelpText(null); setHelpText(null);
onCancelAddLinkClick(); dispatch({ type: 'CANCEL_LINK' });
}; };
const drawPotentialLinkToCursor = e => { const drawPotentialLinkToCursor = e => {
@@ -274,10 +269,7 @@ function VisualizerGraph({
<g id="workflow-g" ref={gRef}> <g id="workflow-g" ref={gRef}>
{nodePositions && [ {nodePositions && [
<WorkflowStartNode <WorkflowStartNode
addingLink={addingLink}
key="start" key="start"
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
showActionTooltip={!readOnly} showActionTooltip={!readOnly}
onUpdateHelpText={setHelpText} onUpdateHelpText={setHelpText}
/>, />,
@@ -288,13 +280,8 @@ function VisualizerGraph({
) { ) {
return ( return (
<VisualizerLink <VisualizerLink
addingLink={addingLink}
key={`link-${link.source.id}-${link.target.id}`} key={`link-${link.source.id}-${link.target.id}`}
link={link} link={link}
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
onDeleteLinkClick={onDeleteLinkClick}
onLinkEditClick={onLinkEditClick}
readOnly={readOnly} readOnly={readOnly}
onUpdateHelpText={setHelpText} onUpdateHelpText={setHelpText}
onUpdateLinkHelp={setLinkHelp} onUpdateLinkHelp={setLinkHelp}
@@ -307,19 +294,8 @@ function VisualizerGraph({
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) { if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
return ( return (
<VisualizerNode <VisualizerNode
addingLink={addingLink}
isAddLinkSourceNode={
addLinkSourceNode && addLinkSourceNode.id === node.id
}
key={`node-${node.id}`} key={`node-${node.id}`}
node={node} node={node}
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
onDeleteNodeClick={onDeleteNodeClick}
onEditNodeClick={onEditNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onViewNodeClick={onViewNodeClick}
readOnly={readOnly} readOnly={readOnly}
onUpdateHelpText={setHelpText} onUpdateHelpText={setHelpText}
updateNodeHelp={setNodeHelp} updateNodeHelp={setNodeHelp}
@@ -346,7 +322,6 @@ function VisualizerGraph({
<div css="position: absolute; top: 75px;right: 20px;display: flex;"> <div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && ( {showTools && (
<WorkflowTools <WorkflowTools
onClose={() => onUpdateShowTools(false)}
onFitGraph={handleFitGraph} onFitGraph={handleFitGraph}
onPan={handlePan} onPan={handlePan}
onPanToMiddle={handlePanToMiddle} onPanToMiddle={handlePanToMiddle}
@@ -354,38 +329,14 @@ function VisualizerGraph({
zoomPercentage={zoomPercentage} zoomPercentage={zoomPercentage}
/> />
)} )}
{showLegend && ( {showLegend && <WorkflowLegend />}
<WorkflowLegend onClose={() => onUpdateShowLegend(false)} />
)}
</div> </div>
</> </>
); );
} }
VisualizerGraph.propTypes = { VisualizerGraph.propTypes = {
addLinkSourceNode: shape(),
addingLink: bool.isRequired,
links: arrayOf(shape()).isRequired,
nodePositions: shape().isRequired,
nodes: arrayOf(shape()).isRequired,
onAddNodeClick: func.isRequired,
onCancelAddLinkClick: func.isRequired,
onConfirmAddLinkClick: func.isRequired,
onDeleteLinkClick: func.isRequired,
onDeleteNodeClick: func.isRequired,
onEditNodeClick: func.isRequired,
onLinkEditClick: func.isRequired,
onStartAddLinkClick: func.isRequired,
onUpdateShowLegend: func.isRequired,
onUpdateShowTools: func.isRequired,
onViewNodeClick: func.isRequired,
readOnly: bool.isRequired, readOnly: bool.isRequired,
showLegend: bool.isRequired,
showTools: bool.isRequired,
};
VisualizerGraph.defaultProps = {
addLinkSourceNode: {},
}; };
export default withI18n()(VisualizerGraph); export default withI18n()(VisualizerGraph);

View File

@@ -1,4 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import styled from 'styled-components'; import styled from 'styled-components';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -19,13 +23,8 @@ const LinkG = styled.g`
`; `;
function VisualizerLink({ function VisualizerLink({
addingLink,
i18n, i18n,
link, link,
nodePositions,
onAddNodeClick,
onDeleteLinkClick,
onLinkEditClick,
onUpdateHelpText, onUpdateHelpText,
onUpdateLinkHelp, onUpdateLinkHelp,
readOnly, readOnly,
@@ -36,6 +35,8 @@ function VisualizerLink({
const [pathStroke, setPathStroke] = useState('#CCCCCC'); const [pathStroke, setPathStroke] = useState('#CCCCCC');
const [tooltipX, setTooltipX] = useState(); const [tooltipX, setTooltipX] = useState();
const [tooltipY, setTooltipY] = useState(); const [tooltipY, setTooltipY] = useState();
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, nodePositions } = useContext(WorkflowStateContext);
const addNodeAction = ( const addNodeAction = (
<WorkflowActionTooltipItem <WorkflowActionTooltipItem
@@ -44,7 +45,11 @@ function VisualizerLink({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onAddNodeClick(link.source.id, link.target.id); dispatch({
type: 'START_ADD_NODE',
sourceNodeId: link.source.id,
targetNodeId: link.target.id,
});
}} }}
onMouseEnter={() => onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Add a new node between these two nodes`)) onUpdateHelpText(i18n._(t`Add a new node between these two nodes`))
@@ -63,7 +68,7 @@ function VisualizerLink({
<WorkflowActionTooltipItem <WorkflowActionTooltipItem
id="link-edit" id="link-edit"
key="edit" key="edit"
onClick={() => onLinkEditClick(link)} onClick={() => dispatch({ type: 'SET_LINK_TO_EDIT', value: link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
> >
@@ -72,7 +77,7 @@ function VisualizerLink({
<WorkflowActionTooltipItem <WorkflowActionTooltipItem
id="link-delete" id="link-delete"
key="delete" key="delete"
onClick={() => onDeleteLinkClick(link)} onClick={() => dispatch({ type: 'START_DELETE_LINK', link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
> >
@@ -142,12 +147,7 @@ function VisualizerLink({
} }
VisualizerLink.propTypes = { VisualizerLink.propTypes = {
addingLink: bool.isRequired,
link: shape().isRequired, link: shape().isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
onDeleteLinkClick: func.isRequired,
onLinkEditClick: func.isRequired,
readOnly: bool.isRequired, readOnly: bool.isRequired,
onUpdateHelpText: func.isRequired, onUpdateHelpText: func.isRequired,
onUpdateLinkHelp: func.isRequired, onUpdateLinkHelp: func.isRequired,

View File

@@ -1,4 +1,8 @@
import React, { useRef, useState } from 'react'; import React, { useContext, useRef, useState } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import styled from 'styled-components'; import styled from 'styled-components';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -38,24 +42,21 @@ const NodeDefaultLabel = styled.p`
`; `;
function VisualizerNode({ function VisualizerNode({
addingLink,
i18n, i18n,
isAddLinkSourceNode,
node, node,
nodePositions,
onAddNodeClick,
onConfirmAddLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onMouseOver, onMouseOver,
onStartAddLinkClick,
onViewNodeClick,
readOnly, readOnly,
onUpdateHelpText, onUpdateHelpText,
updateNodeHelp, updateNodeHelp,
}) { }) {
const ref = useRef(null); const ref = useRef(null);
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, addLinkSourceNode, nodePositions } = useContext(
WorkflowStateContext
);
const isAddLinkSourceNode =
addLinkSourceNode && addLinkSourceNode.id === node.id;
const handleNodeMouseEnter = () => { const handleNodeMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current); ref.current.parentNode.appendChild(ref.current);
@@ -81,7 +82,7 @@ function VisualizerNode({
const handleNodeClick = () => { const handleNodeClick = () => {
if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) { if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) {
onConfirmAddLinkClick(node); dispatch({ type: 'SET_ADD_LINK_TARGET_NODE', value: node });
} }
}; };
@@ -92,7 +93,7 @@ function VisualizerNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onViewNodeClick(node); dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
}} }}
onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
@@ -110,7 +111,7 @@ function VisualizerNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onAddNodeClick(node.id); dispatch({ type: 'START_ADD_NODE', sourceNodeId: node.id });
}} }}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
@@ -124,7 +125,7 @@ function VisualizerNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onEditNodeClick(node); dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
}} }}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
@@ -137,7 +138,7 @@ function VisualizerNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onStartAddLinkClick(node); dispatch({ type: 'SELECT_SOURCE_FOR_LINKING', node });
}} }}
onMouseEnter={() => onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Link to an available node`)) onUpdateHelpText(i18n._(t`Link to an available node`))
@@ -152,7 +153,7 @@ function VisualizerNode({
onClick={() => { onClick={() => {
onUpdateHelpText(null); onUpdateHelpText(null);
setHovering(false); setHovering(false);
onDeleteNodeClick(node); dispatch({ type: 'SET_NODE_TO_DELETE', value: node });
}} }}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))} onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => onUpdateHelpText(null)} onMouseLeave={() => onUpdateHelpText(null)}
@@ -214,24 +215,14 @@ function VisualizerNode({
} }
VisualizerNode.propTypes = { VisualizerNode.propTypes = {
addingLink: bool.isRequired,
isAddLinkSourceNode: bool,
node: shape().isRequired, node: shape().isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
onConfirmAddLinkClick: func.isRequired,
onDeleteNodeClick: func.isRequired,
onEditNodeClick: func.isRequired,
onMouseOver: func, onMouseOver: func,
onStartAddLinkClick: func.isRequired,
onViewNodeClick: func.isRequired,
readOnly: bool.isRequired, readOnly: bool.isRequired,
onUpdateHelpText: func.isRequired, onUpdateHelpText: func.isRequired,
updateNodeHelp: func.isRequired, updateNodeHelp: func.isRequired,
}; };
VisualizerNode.defaultProps = { VisualizerNode.defaultProps = {
isAddLinkSourceNode: false,
onMouseOver: () => {}, onMouseOver: () => {},
}; };

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func } from 'prop-types';
import { Button as PFButton } from '@patternfly/react-core'; import { Button as PFButton } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -29,7 +29,8 @@ const StartPanelWrapper = styled.div`
justify-content: center; justify-content: center;
`; `;
function VisualizerStartScreen({ i18n, onStartClick }) { function VisualizerStartScreen({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return ( return (
<div css="flex: 1"> <div css="flex: 1">
<StartPanelWrapper> <StartPanelWrapper>
@@ -37,7 +38,9 @@ function VisualizerStartScreen({ i18n, onStartClick }) {
<p>{i18n._(t`Please click the Start button to begin.`)}</p> <p>{i18n._(t`Please click the Start button to begin.`)}</p>
<Button <Button
aria-label={i18n._(t`Start`)} aria-label={i18n._(t`Start`)}
onClick={() => onStartClick(1)} onClick={() =>
dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 })
}
variant="primary" variant="primary"
> >
{i18n._(t`Start`)} {i18n._(t`Start`)}
@@ -48,8 +51,4 @@ function VisualizerStartScreen({ i18n, onStartClick }) {
); );
} }
VisualizerStartScreen.propTypes = {
onStartClick: func.isRequired,
};
export default withI18n()(VisualizerStartScreen); export default withI18n()(VisualizerStartScreen);

View File

@@ -1,7 +1,11 @@
import React from 'react'; import React, { useContext } from 'react';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { arrayOf, bool, func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core'; import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import { import {
BookIcon, BookIcon,
@@ -36,18 +40,11 @@ const ActionButton = styled(Button)`
} }
`; `;
function VisualizerToolbar({ function VisualizerToolbar({ i18n, onClose, onSave, template }) {
i18n, const dispatch = useContext(WorkflowDispatchContext);
legendShown,
nodes, const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
onClose,
onDeleteAllClick,
onLegendToggle,
onSave,
onToolsToggle,
template,
toolsShown,
}) {
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return ( return (
@@ -64,8 +61,8 @@ function VisualizerToolbar({
<VerticalSeparator /> <VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
<ActionButton <ActionButton
isActive={legendShown} isActive={showLegend}
onClick={onLegendToggle} onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain" variant="plain"
> >
<CompassIcon /> <CompassIcon />
@@ -73,8 +70,8 @@ function VisualizerToolbar({
</Tooltip> </Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom"> <Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton <ActionButton
isActive={toolsShown} isActive={showTools}
onClick={onToolsToggle} onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain" variant="plain"
> >
<WrenchIcon /> <WrenchIcon />
@@ -90,7 +87,12 @@ function VisualizerToolbar({
<ActionButton <ActionButton
aria-label={i18n._(t`Delete all nodes`)} aria-label={i18n._(t`Delete all nodes`)}
isDisabled={totalNodes === 0} isDisabled={totalNodes === 0}
onClick={onDeleteAllClick} onClick={() =>
dispatch({
type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
value: true,
})
}
variant="plain" variant="plain"
> >
<TrashAltIcon /> <TrashAltIcon />
@@ -115,19 +117,9 @@ function VisualizerToolbar({
} }
VisualizerToolbar.propTypes = { VisualizerToolbar.propTypes = {
legendShown: bool.isRequired,
nodes: arrayOf(shape()),
onClose: func.isRequired, onClose: func.isRequired,
onDeleteAllClick: func.isRequired,
onLegendToggle: func.isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onToolsToggle: func.isRequired,
template: shape().isRequired, template: shape().isRequired,
toolsShown: bool.isRequired,
};
VisualizerToolbar.defaultProps = {
nodes: [],
}; };
export default withI18n()(VisualizerToolbar); export default withI18n()(VisualizerToolbar);