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 { t } from '@lingui/macro';
import styled from 'styled-components';
import { func } from 'prop-types';
import {
ExclamationTriangleIcon,
PauseIcon,
@ -77,12 +77,14 @@ const Close = styled(TimesIcon)`
top: 15px;
`;
function WorkflowLegend({ i18n, onClose }) {
function WorkflowLegend({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<Wrapper>
<Header>
<b>{i18n._(t`Legend`)}</b>
<Close onClick={onClose} />
<Close onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} />
</Header>
<Legend>
<li>
@ -128,8 +130,4 @@ function WorkflowLegend({ i18n, onClose }) {
);
}
WorkflowLegend.propTypes = {
onClose: func.isRequired,
};
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 { withI18n } from '@lingui/react';
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 { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
import {
@ -14,16 +18,11 @@ const StartG = styled.g`
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
`;
function WorkflowStartNode({
addingLink,
i18n,
nodePositions,
onAddNodeClick,
onUpdateHelpText,
showActionTooltip,
}) {
function WorkflowStartNode({ i18n, onUpdateHelpText, showActionTooltip }) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, nodePositions } = useContext(WorkflowStateContext);
const handleNodeMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current);
@ -62,7 +61,7 @@ function WorkflowStartNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onAddNodeClick(1);
dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 });
}}
>
<PlusIcon />
@ -77,16 +76,11 @@ function WorkflowStartNode({
}
WorkflowStartNode.propTypes = {
addingLink: bool,
nodePositions: shape().isRequired,
onAddNodeClick: func,
showActionTooltip: bool.isRequired,
onUpdateHelpText: func,
};
WorkflowStartNode.defaultProps = {
addingLink: false,
onAddNodeClick: () => {},
onUpdateHelpText: () => {},
};

View File

@ -1,5 +1,6 @@
import React from 'react';
import { mount } from 'enzyme';
import { WorkflowStateContext } from '@contexts/Workflow';
import WorkflowStartNode from './WorkflowStartNode';
const nodePositions = {
@ -13,10 +14,12 @@ describe('WorkflowStartNode', () => {
test('mounts successfully', () => {
const wrapper = mount(
<svg>
<WorkflowStartNode
nodePositions={nodePositions}
showActionTooltip={false}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowStartNode
nodePositions={nodePositions}
showActionTooltip={false}
/>
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper).toHaveLength(1);
@ -24,7 +27,9 @@ describe('WorkflowStartNode', () => {
test('tooltip shown on hover', () => {
const wrapper = mount(
<svg>
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip />
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip />
</WorkflowStateContext.Provider>
</svg>
);
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 { t } from '@lingui/macro';
import styled from 'styled-components';
@ -53,13 +54,13 @@ const Close = styled(TimesIcon)`
function WorkflowTools({
i18n,
onClose,
onFitGraph,
onPan,
onPanToMiddle,
onZoomChange,
zoomPercentage,
}) {
const dispatch = useContext(WorkflowDispatchContext);
const zoomIn = () => {
const newScale =
Math.ceil((zoomPercentage + 10) / 10) * 10 < 200
@ -80,7 +81,7 @@ function WorkflowTools({
<Wrapper>
<Header>
<b>{i18n._(t`Tools`)}</b>
<Close onClick={onClose} />
<Close onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} />
</Header>
<Tools>
<Tooltip
@ -178,7 +179,6 @@ function WorkflowTools({
}
WorkflowTools.propTypes = {
onClose: func.isRequired,
onFitGraph: func.isRequired,
onPan: 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 { t } from '@lingui/macro';
import styled from 'styled-components';
import { shape } from 'prop-types';
import { CardBody as PFCardBody } from '@patternfly/react-core';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { layoutGraph } from '@components/Workflow/WorkflowUtils';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import workflowReducer from '@components/Workflow/workflowReducer';
import { WorkflowJobsAPI } from '@api';
import WorkflowOutputGraph from './WorkflowOutputGraph';
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
@ -36,148 +40,50 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
};
function WorkflowOutput({ job, i18n }) {
const [contentError, setContentError] = useState(null);
const [graphLinks, setGraphLinks] = useState([]);
const [graphNodes, setGraphNodes] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [nodePositions, setNodePositions] = useState(null);
const [showLegend, setShowLegend] = useState(false);
const [showTools, setShowTools] = useState(false);
const [state, dispatch] = useReducer(workflowReducer, {
contentError: null,
isLoading: true,
links: [],
nextNodeId: 0,
nodePositions: null,
nodes: [],
showLegend: false,
showTools: false,
});
const { contentError, isLoading, links, nodePositions, nodes } = state;
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() {
try {
const nodes = await fetchWorkflowNodes(job.id);
buildGraphArrays(nodes);
const workflowNodes = await fetchWorkflowNodes(job.id);
dispatch({
type: 'GENERATE_NODES_AND_LINKS',
nodes: workflowNodes,
i18n,
});
} catch (error) {
setContentError(error);
dispatch({ type: 'SET_CONTENT_ERROR', value: error });
} finally {
setIsLoading(false);
dispatch({ type: 'SET_IS_LOADING', value: false });
}
}
fetchData();
}, [job.id, job.unified_job_template, i18n]);
}, [job.id, i18n]);
// Update positions of nodes/links
useEffect(() => {
if (graphNodes) {
if (nodes) {
const newNodePositions = {};
const g = layoutGraph(graphNodes, graphLinks);
const g = layoutGraph(nodes, links);
g.nodes().forEach(node => {
newNodePositions[node] = g.node(node);
});
setNodePositions(newNodePositions);
dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
}
}, [graphLinks, graphNodes]);
}, [links, nodes]);
if (isLoading) {
return (
@ -196,29 +102,16 @@ function WorkflowOutput({ job, i18n }) {
}
return (
<CardBody>
<Wrapper>
<WorkflowOutputToolbar
job={job}
legendShown={showLegend}
nodes={graphNodes}
onLegendToggle={() => setShowLegend(!showLegend)}
onToolsToggle={() => setShowTools(!showTools)}
toolsShown={showTools}
/>
{nodePositions && (
<WorkflowOutputGraph
links={graphLinks}
nodePositions={nodePositions}
nodes={graphNodes}
onUpdateShowLegend={setShowLegend}
onUpdateShowTools={setShowTools}
showLegend={showLegend}
showTools={showTools}
/>
)}
</Wrapper>
</CardBody>
<WorkflowStateContext.Provider value={state}>
<WorkflowDispatchContext.Provider value={dispatch}>
<CardBody>
<Wrapper>
<WorkflowOutputToolbar job={job} />
{nodePositions && <WorkflowOutputGraph />}
</Wrapper>
</CardBody>
</WorkflowDispatchContext.Provider>
</WorkflowStateContext.Provider>
);
}

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

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

View File

@ -1,5 +1,6 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { mount } from 'enzyme';
import { WorkflowStateContext } from '@contexts/Workflow';
import WorkflowOutputLink from './WorkflowOutputLink';
const link = {
@ -28,13 +29,15 @@ const nodePositions = {
describe('WorkflowOutputLink', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
const wrapper = mount(
<svg>
<WorkflowOutputLink
link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={() => {}}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowOutputLink
link={link}
nodePositions={nodePositions}
onUpdateLinkHelp={() => {}}
/>
</WorkflowStateContext.Provider>
</svg>
);
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -55,14 +56,8 @@ const NodeDefaultLabel = styled.p`
white-space: nowrap;
`;
function WorkflowOutputNode({
history,
i18n,
mouseEnter,
mouseLeave,
node,
nodePositions,
}) {
function WorkflowOutputNode({ history, i18n, mouseEnter, mouseLeave, node }) {
const { nodePositions } = useContext(WorkflowStateContext);
let borderColor = '#93969A';
if (node.job) {
@ -105,7 +100,7 @@ function WorkflowOutputNode({
/>
<NodeContents height="60" width="180">
{node.job ? (
<Fragment>
<>
<JobTopLine>
<StatusIcon status={node.job.status} />
<p>
@ -115,7 +110,7 @@ function WorkflowOutputNode({
</p>
</JobTopLine>
<Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>
</Fragment>
</>
) : (
<NodeDefaultLabel>
{node.unifiedJobTemplate
@ -134,7 +129,6 @@ WorkflowOutputNode.propTypes = {
mouseEnter: func.isRequired,
mouseLeave: func.isRequired,
node: shape().isRequired,
nodePositions: shape().isRequired,
};
export default withI18n()(withRouter(WorkflowOutputNode));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputNode from './WorkflowOutputNode';
@ -48,12 +49,13 @@ describe('WorkflowOutputNode', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
nodePositions={nodePositions}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
/>
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper).toHaveLength(1);
@ -61,12 +63,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job and Job Template exist', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
nodePositions={nodePositions}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithJT}
/>
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true);
@ -75,12 +78,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job Template deleted', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithoutJT}
nodePositions={nodePositions}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={nodeWithoutJT}
/>
</WorkflowStateContext.Provider>
</svg>
);
expect(wrapper.contains(<p>DELETED</p>)).toEqual(true);
@ -89,12 +93,13 @@ describe('WorkflowOutputNode', () => {
test('node contents displayed correctly when Job deleted', () => {
const wrapper = mountWithContexts(
<svg>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{ id: 2 }}
nodePositions={nodePositions}
/>
<WorkflowStateContext.Provider value={{ nodePositions }}>
<WorkflowOutputNode
mouseEnter={() => {}}
mouseLeave={() => {}}
node={{ id: 2 }}
/>
</WorkflowStateContext.Provider>
</svg>
);
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 { 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 { CompassIcon, WrenchIcon } from '@patternfly/react-icons';
import { StatusIcon } from '@components/Sparkline';
@ -53,15 +57,11 @@ const StatusIconWithMargin = styled(StatusIcon)`
margin-right: 20px;
`;
function WorkflowOutputToolbar({
i18n,
job,
legendShown,
nodes,
onLegendToggle,
onToolsToggle,
toolsShown,
}) {
function WorkflowOutputToolbar({ i18n, job }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return (
@ -76,8 +76,8 @@ function WorkflowOutputToolbar({
<VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
<ActionButton
isActive={legendShown}
onClick={onLegendToggle}
isActive={showLegend}
onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain"
>
<CompassIcon />
@ -85,8 +85,8 @@ function WorkflowOutputToolbar({
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
isActive={toolsShown}
onClick={onToolsToggle}
isActive={showTools}
onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain"
>
<WrenchIcon />
@ -99,15 +99,6 @@ function WorkflowOutputToolbar({
WorkflowOutputToolbar.propTypes = {
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);

View File

@ -1,4 +1,5 @@
import React from 'react';
import { WorkflowStateContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
@ -7,17 +8,18 @@ const job = {
status: 'successful',
};
const workflowContext = {
nodes: [],
showLegend: false,
showTools: false,
};
describe('WorkflowOutputToolbar', () => {
test('mounts successfully', () => {
const wrapper = mountWithContexts(
<WorkflowOutputToolbar
job={job}
legendShown={false}
nodes={[]}
onLegendToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
<WorkflowStateContext.Provider value={workflowContext}>
<WorkflowOutputToolbar job={job} />
</WorkflowStateContext.Provider>
);
expect(wrapper).toHaveLength(1);
});
@ -36,14 +38,9 @@ describe('WorkflowOutputToolbar', () => {
},
];
const wrapper = mountWithContexts(
<WorkflowOutputToolbar
job={job}
legendShown={false}
nodes={nodes}
onLegendToggle={() => {}}
onToolsToggle={() => {}}
toolsShown={false}
/>
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
<WorkflowOutputToolbar job={job} />
</WorkflowStateContext.Provider>
);
// The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func } from 'prop-types';
import AlertModal from '@components/AlertModal';
function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
function DeleteAllNodesModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<AlertModal
actions={[
@ -13,7 +14,7 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
key="remove"
variant="danger"
aria-label={i18n._(t`Confirm removal of all nodes`)}
onClick={() => onConfirm()}
onClick={() => dispatch({ type: 'DELETE_ALL_NODES' })}
>
{i18n._(t`Remove`)}
</Button>,
@ -21,13 +22,13 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel node removal`)}
onClick={onCancel}
onClick={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
>
{i18n._(t`Cancel`)}
</Button>,
]}
isOpen
onClose={onCancel}
onClose={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
title={i18n._(t`Remove All Nodes`)}
variant="danger"
>
@ -40,9 +41,4 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
);
}
DeleteAllNodesModal.propTypes = {
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import AlertModal from '@components/AlertModal';
function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
function LinkDeleteModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { linkToDelete } = useContext(WorkflowStateContext);
return (
<AlertModal
variant="danger"
title="Remove Link"
isOpen={linkToDelete}
onClose={onCancel}
onClose={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
actions={[
<Button
aria-label={i18n._(t`Confirm link removal`)}
key="remove"
onClick={() => onConfirm()}
onClick={() => dispatch({ type: 'DELETE_LINK' })}
variant="danger"
>
{i18n._(t`Remove`)}
@ -24,7 +29,7 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
<Button
aria-label={i18n._(t`Cancel link removal`)}
key="cancel"
onClick={onCancel}
onClick={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
variant="secondary"
>
{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);

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

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 {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { bool, func, node, shape } from 'prop-types';
import { bool, node, func } from 'prop-types';
import {
Button,
WizardContextConsumer,
@ -12,15 +16,10 @@ import Wizard from '@components/Wizard';
import { NodeTypeStep } from './NodeTypeStep';
import { RunStep, NodeNextButton } from '.';
function NodeModal({
askLinkType,
history,
i18n,
nodeToEdit,
onClose,
onSave,
title,
}) {
function NodeModal({ askLinkType, history, i18n, onSave, title }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToEdit } = useContext(WorkflowStateContext);
let defaultApprovalDescription = '';
let defaultApprovalName = '';
let defaultApprovalTimeout = 0;
@ -104,16 +103,12 @@ function NodeModal({
}
: nodeResource;
onSave({
linkType,
nodeResource: resource,
nodeType,
});
onSave(linkType, resource, nodeType);
};
const handleCancel = () => {
clearQueryParams();
onClose();
dispatch({ type: 'CANCEL_NODE_MODAL' });
};
const handleNodeTypeChange = newNodeType => {
@ -211,14 +206,8 @@ function NodeModal({
NodeModal.propTypes = {
askLinkType: bool.isRequired,
nodeToEdit: shape(),
onClose: func.isRequired,
onSave: func.isRequired,
title: node.isRequired,
};
NodeModal.defaultProps = {
nodeToEdit: null,
};
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 { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import { func } from 'prop-types';
function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
function UnsavedChangesModal({ i18n, onSaveAndExit, onExit }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<Modal
width={600}
isOpen
title={i18n._(t`Warning: Unsaved Changes`)}
onClose={onCancel}
onClose={() => dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' })}
actions={[
<Button
key="exit"
@ -41,7 +43,6 @@ function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
}
UnsavedChangesModal.propTypes = {
onCancel: func.isRequired,
onExit: func.isRequired,
onSaveAndExit: func.isRequired,
};

View File

@ -1,6 +1,2 @@
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';

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 { t } from '@lingui/macro';
import styled from 'styled-components';
import { arrayOf, bool, func, shape } from 'prop-types';
import { bool } from 'prop-types';
import * as d3 from 'd3';
import {
getScaleAndOffsetToFit,
@ -32,28 +36,7 @@ const WorkflowSVG = styled.svg`
height: 100%;
`;
function VisualizerGraph({
addLinkSourceNode,
addingLink,
i18n,
links,
nodePositions,
nodes,
onAddNodeClick,
onCancelAddLinkClick,
onConfirmAddLinkClick,
onDeleteLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onLinkEditClick,
onStartAddLinkClick,
onUpdateShowLegend,
onUpdateShowTools,
onViewNodeClick,
readOnly,
showLegend,
showTools,
}) {
function VisualizerGraph({ i18n, readOnly }) {
const [helpText, setHelpText] = useState(null);
const [linkHelp, setLinkHelp] = useState();
const [nodeHelp, setNodeHelp] = useState();
@ -61,6 +44,18 @@ function VisualizerGraph({
const svgRef = useRef(null);
const gRef = useRef(null);
const {
addLinkSourceNode,
addingLink,
links,
nodePositions,
nodes,
showLegend,
showTools,
} = useContext(WorkflowStateContext);
const dispatch = useContext(WorkflowDispatchContext);
const drawPotentialLinkToNode = node => {
if (node.id !== addLinkSourceNode.id) {
const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
@ -81,7 +76,7 @@ function VisualizerGraph({
const handleBackgroundClick = () => {
setHelpText(null);
onCancelAddLinkClick();
dispatch({ type: 'CANCEL_LINK' });
};
const drawPotentialLinkToCursor = e => {
@ -274,10 +269,7 @@ function VisualizerGraph({
<g id="workflow-g" ref={gRef}>
{nodePositions && [
<WorkflowStartNode
addingLink={addingLink}
key="start"
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
showActionTooltip={!readOnly}
onUpdateHelpText={setHelpText}
/>,
@ -288,13 +280,8 @@ function VisualizerGraph({
) {
return (
<VisualizerLink
addingLink={addingLink}
key={`link-${link.source.id}-${link.target.id}`}
link={link}
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
onDeleteLinkClick={onDeleteLinkClick}
onLinkEditClick={onLinkEditClick}
readOnly={readOnly}
onUpdateHelpText={setHelpText}
onUpdateLinkHelp={setLinkHelp}
@ -307,19 +294,8 @@ function VisualizerGraph({
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
return (
<VisualizerNode
addingLink={addingLink}
isAddLinkSourceNode={
addLinkSourceNode && addLinkSourceNode.id === node.id
}
key={`node-${node.id}`}
node={node}
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
onDeleteNodeClick={onDeleteNodeClick}
onEditNodeClick={onEditNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onViewNodeClick={onViewNodeClick}
readOnly={readOnly}
onUpdateHelpText={setHelpText}
updateNodeHelp={setNodeHelp}
@ -346,7 +322,6 @@ function VisualizerGraph({
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && (
<WorkflowTools
onClose={() => onUpdateShowTools(false)}
onFitGraph={handleFitGraph}
onPan={handlePan}
onPanToMiddle={handlePanToMiddle}
@ -354,38 +329,14 @@ function VisualizerGraph({
zoomPercentage={zoomPercentage}
/>
)}
{showLegend && (
<WorkflowLegend onClose={() => onUpdateShowLegend(false)} />
)}
{showLegend && <WorkflowLegend />}
</div>
</>
);
}
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,
showLegend: bool.isRequired,
showTools: bool.isRequired,
};
VisualizerGraph.defaultProps = {
addLinkSourceNode: {},
};
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -19,13 +23,8 @@ const LinkG = styled.g`
`;
function VisualizerLink({
addingLink,
i18n,
link,
nodePositions,
onAddNodeClick,
onDeleteLinkClick,
onLinkEditClick,
onUpdateHelpText,
onUpdateLinkHelp,
readOnly,
@ -36,6 +35,8 @@ function VisualizerLink({
const [pathStroke, setPathStroke] = useState('#CCCCCC');
const [tooltipX, setTooltipX] = useState();
const [tooltipY, setTooltipY] = useState();
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, nodePositions } = useContext(WorkflowStateContext);
const addNodeAction = (
<WorkflowActionTooltipItem
@ -44,7 +45,11 @@ function VisualizerLink({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onAddNodeClick(link.source.id, link.target.id);
dispatch({
type: 'START_ADD_NODE',
sourceNodeId: link.source.id,
targetNodeId: link.target.id,
});
}}
onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Add a new node between these two nodes`))
@ -63,7 +68,7 @@ function VisualizerLink({
<WorkflowActionTooltipItem
id="link-edit"
key="edit"
onClick={() => onLinkEditClick(link)}
onClick={() => dispatch({ type: 'SET_LINK_TO_EDIT', value: link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => onUpdateHelpText(null)}
>
@ -72,7 +77,7 @@ function VisualizerLink({
<WorkflowActionTooltipItem
id="link-delete"
key="delete"
onClick={() => onDeleteLinkClick(link)}
onClick={() => dispatch({ type: 'START_DELETE_LINK', link })}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => onUpdateHelpText(null)}
>
@ -142,12 +147,7 @@ function VisualizerLink({
}
VisualizerLink.propTypes = {
addingLink: bool.isRequired,
link: shape().isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
onDeleteLinkClick: func.isRequired,
onLinkEditClick: func.isRequired,
readOnly: bool.isRequired,
onUpdateHelpText: 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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -38,24 +42,21 @@ const NodeDefaultLabel = styled.p`
`;
function VisualizerNode({
addingLink,
i18n,
isAddLinkSourceNode,
node,
nodePositions,
onAddNodeClick,
onConfirmAddLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onMouseOver,
onStartAddLinkClick,
onViewNodeClick,
readOnly,
onUpdateHelpText,
updateNodeHelp,
}) {
const ref = useRef(null);
const [hovering, setHovering] = useState(false);
const dispatch = useContext(WorkflowDispatchContext);
const { addingLink, addLinkSourceNode, nodePositions } = useContext(
WorkflowStateContext
);
const isAddLinkSourceNode =
addLinkSourceNode && addLinkSourceNode.id === node.id;
const handleNodeMouseEnter = () => {
ref.current.parentNode.appendChild(ref.current);
@ -81,7 +82,7 @@ function VisualizerNode({
const handleNodeClick = () => {
if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) {
onConfirmAddLinkClick(node);
dispatch({ type: 'SET_ADD_LINK_TARGET_NODE', value: node });
}
};
@ -92,7 +93,7 @@ function VisualizerNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onViewNodeClick(node);
dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => onUpdateHelpText(null)}
@ -110,7 +111,7 @@ function VisualizerNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onAddNodeClick(node.id);
dispatch({ type: 'START_ADD_NODE', sourceNodeId: node.id });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => onUpdateHelpText(null)}
@ -124,7 +125,7 @@ function VisualizerNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onEditNodeClick(node);
dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => onUpdateHelpText(null)}
@ -137,7 +138,7 @@ function VisualizerNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onStartAddLinkClick(node);
dispatch({ type: 'SELECT_SOURCE_FOR_LINKING', node });
}}
onMouseEnter={() =>
onUpdateHelpText(i18n._(t`Link to an available node`))
@ -152,7 +153,7 @@ function VisualizerNode({
onClick={() => {
onUpdateHelpText(null);
setHovering(false);
onDeleteNodeClick(node);
dispatch({ type: 'SET_NODE_TO_DELETE', value: node });
}}
onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => onUpdateHelpText(null)}
@ -214,24 +215,14 @@ function VisualizerNode({
}
VisualizerNode.propTypes = {
addingLink: bool.isRequired,
isAddLinkSourceNode: bool,
node: shape().isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
onConfirmAddLinkClick: func.isRequired,
onDeleteNodeClick: func.isRequired,
onEditNodeClick: func.isRequired,
onMouseOver: func,
onStartAddLinkClick: func.isRequired,
onViewNodeClick: func.isRequired,
readOnly: bool.isRequired,
onUpdateHelpText: func.isRequired,
updateNodeHelp: func.isRequired,
};
VisualizerNode.defaultProps = {
isAddLinkSourceNode: false,
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 { t } from '@lingui/macro';
import { func } from 'prop-types';
import { Button as PFButton } from '@patternfly/react-core';
import styled from 'styled-components';
@ -29,7 +29,8 @@ const StartPanelWrapper = styled.div`
justify-content: center;
`;
function VisualizerStartScreen({ i18n, onStartClick }) {
function VisualizerStartScreen({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
return (
<div css="flex: 1">
<StartPanelWrapper>
@ -37,7 +38,9 @@ function VisualizerStartScreen({ i18n, onStartClick }) {
<p>{i18n._(t`Please click the Start button to begin.`)}</p>
<Button
aria-label={i18n._(t`Start`)}
onClick={() => onStartClick(1)}
onClick={() =>
dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 })
}
variant="primary"
>
{i18n._(t`Start`)}
@ -48,8 +51,4 @@ function VisualizerStartScreen({ i18n, onStartClick }) {
);
}
VisualizerStartScreen.propTypes = {
onStartClick: func.isRequired,
};
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 { 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 {
BookIcon,
@ -36,18 +40,11 @@ const ActionButton = styled(Button)`
}
`;
function VisualizerToolbar({
i18n,
legendShown,
nodes,
onClose,
onDeleteAllClick,
onLegendToggle,
onSave,
onToolsToggle,
template,
toolsShown,
}) {
function VisualizerToolbar({ i18n, onClose, onSave, template }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return (
@ -64,8 +61,8 @@ function VisualizerToolbar({
<VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
<ActionButton
isActive={legendShown}
onClick={onLegendToggle}
isActive={showLegend}
onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
variant="plain"
>
<CompassIcon />
@ -73,8 +70,8 @@ function VisualizerToolbar({
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
isActive={toolsShown}
onClick={onToolsToggle}
isActive={showTools}
onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
variant="plain"
>
<WrenchIcon />
@ -90,7 +87,12 @@ function VisualizerToolbar({
<ActionButton
aria-label={i18n._(t`Delete all nodes`)}
isDisabled={totalNodes === 0}
onClick={onDeleteAllClick}
onClick={() =>
dispatch({
type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
value: true,
})
}
variant="plain"
>
<TrashAltIcon />
@ -115,19 +117,9 @@ function VisualizerToolbar({
}
VisualizerToolbar.propTypes = {
legendShown: bool.isRequired,
nodes: arrayOf(shape()),
onClose: func.isRequired,
onDeleteAllClick: func.isRequired,
onLegendToggle: func.isRequired,
onSave: func.isRequired,
onToolsToggle: func.isRequired,
template: shape().isRequired,
toolsShown: bool.isRequired,
};
VisualizerToolbar.defaultProps = {
nodes: [],
};
export default withI18n()(VisualizerToolbar);