Stabilized workflow visualizer and output point. Workflow jobs can be viewed and workflows can be built (without jt prompting).

This commit is contained in:
mabashian 2020-01-20 15:25:07 -05:00
parent 50ba4f9759
commit 1d84d03566
56 changed files with 1088 additions and 1644 deletions

View File

@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import SelectableCard from '@components/SelectableCard';
import Wizard from '@components/Wizard';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import { SelectableCard } from '@components/SelectableCard';
import { Wizard } from '@components/Wizard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>

View File

@ -72,6 +72,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Name"
value="jane brown"
@ -83,6 +84,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Team Roles"
value={
@ -133,6 +135,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Name"
value="jane brown"
@ -144,6 +147,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Team Roles"
value={
@ -217,6 +221,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Name"
value="jane brown"
@ -228,6 +233,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Team Roles"
value={
@ -400,6 +406,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
data-pf-content={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Name"
value="jane brown"
@ -573,6 +580,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
data-pf-content={true}
>
<Detail
alwaysVisible={false}
fullWidth={false}
label="Team Roles"
value={

View File

@ -52,6 +52,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
SelectableCard.propTypes = {
label: PropTypes.string,
description: PropTypes.string,
onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool,
};

View File

@ -1 +1 @@
export { default as SelectableCard } from './SelectableCard';
export { default } from './SelectableCard';

View File

@ -4,7 +4,12 @@ import Wizard from './Wizard';
describe('Wizard', () => {
test('renders the expected content', () => {
const wrapper = mount(<Wizard />);
expect(wrapper).toMatchSnapshot();
const wrapper = mount(
<Wizard
title="Simple Wizard"
steps={[{ name: 'Step 1', component: <p>Step 1</p> }]}
/>
);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -1 +1 @@
export { default as Wizard } from './Wizard';
export { default } from './Wizard';

View File

@ -1,5 +1,6 @@
import React from 'react';
import styled from 'styled-components';
import { node, number } from 'prop-types';
const TooltipContents = styled.div`
display: flex;
@ -10,32 +11,32 @@ const TooltipArrows = styled.div`
`;
const TooltipArrowOuter = styled.div`
border-bottom: 10px solid transparent;
border-right: 10px solid #c4c4c4;
border-top: 10px solid transparent;
height: 0;
margin: auto;
position: absolute;
top: calc(50% - 10px);
width: 0;
height: 0;
border-right: 10px solid #c4c4c4;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
margin: auto;
`;
const TooltipArrowInner = styled.div`
position: absolute;
top: calc(50% - 10px);
left: 2px;
width: 0;
height: 0;
border-bottom: 10px solid transparent;
border-right: 10px solid white;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
height: 0;
left: 2px;
margin: auto;
position: absolute;
top: calc(50% - 10px);
width: 0;
`;
const TooltipActions = styled.div`
background-color: white;
border: 1px solid #c4c4c4;
border-radius: 2px;
border: 1px solid #c4c4c4;
padding: 5px;
`;
@ -59,4 +60,10 @@ function WorkflowActionTooltip({ actions, pointX, pointY }) {
);
}
WorkflowActionTooltip.propTypes = {
actions: node.isRequired,
pointX: number.isRequired,
pointY: number.isRequired,
};
export default WorkflowActionTooltip;

View File

@ -1,15 +1,16 @@
import React from 'react';
import styled from 'styled-components';
import { func } from 'prop-types';
const TooltipItem = styled.div`
height: 25px;
width: 25px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
cursor: pointer;
display: flex;
font-size: 12px;
height: 25px;
justify-content: center;
width: 25px;
&:hover {
color: white;
@ -21,21 +22,33 @@ const TooltipItem = styled.div`
}
`;
function WorkflowActionTooltip({
function WorkflowActionTooltipItem({
children,
onClick,
onMouseEnter,
onMouseLeave,
onClick,
}) {
return (
<TooltipItem
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
>
{children}
</TooltipItem>
);
}
export default WorkflowActionTooltip;
WorkflowActionTooltipItem.propTypes = {
onClick: func,
onMouseEnter: func,
onMouseLeave: func,
};
WorkflowActionTooltipItem.defaultProps = {
onClick: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
};
export default WorkflowActionTooltipItem;

View File

@ -2,20 +2,20 @@ import React from 'react';
import styled from 'styled-components';
const Outer = styled.div`
position: relative;
height: 0;
pointer-events: none;
position: relative;
`;
const Inner = styled.div`
position: absolute;
left: 10px;
top: 10px;
background-color: #383f44;
color: white;
padding: 5px 10px;
border-radius: 2px;
color: white;
left: 10px;
max-width: 300px;
padding: 5px 10px;
position: absolute;
top: 10px;
`;
function WorkflowHelp({ children }) {

View File

@ -5,50 +5,50 @@ import styled from 'styled-components';
import { ExclamationTriangleIcon, PauseIcon } from '@patternfly/react-icons';
const Wrapper = styled.div`
border: 1px solid #c7c7c7;
background-color: white;
min-width: 100px;
border: 1px solid #c7c7c7;
margin-left: 20px;
min-width: 100px;
`;
const Header = styled.div`
padding: 10px;
border-bottom: 1px solid #c7c7c7;
padding: 10px;
`;
const Key = styled.ul`
padding: 5px 10px;
li {
padding: 5px 0px;
display: flex;
align-items: center;
display: flex;
padding: 5px 0px;
}
`;
const NodeTypeLetter = styled.div`
font-size: 10px;
color: white;
text-align: center;
line-height: 20px;
background-color: #393f43;
border-radius: 50%;
color: white;
font-size: 10px;
height: 20px;
width: 20px;
line-height: 20px;
margin-right: 10px;
text-align: center;
width: 20px;
`;
const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
color: #f0ad4d;
margin-right: 10px;
height: 20px;
margin-right: 10px;
width: 20px;
`;
const Link = styled.div`
height: 5px;
width: 20px;
margin-right: 10px;
width: 20px;
`;
const SuccessLink = styled(Link)`
@ -63,7 +63,7 @@ const AlwaysLink = styled(Link)`
background-color: #337ab7;
`;
function VisualizerKey({ i18n }) {
function WorkflowKey({ i18n }) {
return (
<Wrapper>
<Header>
@ -113,4 +113,4 @@ function VisualizerKey({ i18n }) {
);
}
export default withI18n()(VisualizerKey);
export default withI18n()(WorkflowKey);

View File

@ -2,11 +2,12 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { shape } from 'prop-types';
const GridDL = styled.dl`
column-gap: 15px;
display: grid;
grid-template-columns: max-content;
column-gap: 15px;
row-gap: 0px;
dt {
grid-column-start: 1;
@ -18,7 +19,7 @@ const GridDL = styled.dl`
function WorkflowLinkHelp({ link, i18n }) {
let linkType;
switch (link.edgeType) {
switch (link.linkType) {
case 'always':
linkType = i18n._(t`Always`);
break;
@ -42,4 +43,8 @@ function WorkflowLinkHelp({ link, i18n }) {
);
}
WorkflowLinkHelp.propTypes = {
link: shape().isRequired,
};
export default withI18n()(WorkflowLinkHelp);

View File

@ -2,12 +2,13 @@ import React, { Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { shape } from 'prop-types';
import { secondsToHHMMSS } from '@util/dates';
const GridDL = styled.dl`
column-gap: 15px;
display: grid;
grid-template-columns: max-content;
column-gap: 15px;
row-gap: 0px;
dt {
grid-column-start: 1;
@ -134,4 +135,8 @@ function WorkflowNodeHelp({ node, i18n }) {
);
}
WorkflowNodeHelp.propTypes = {
node: shape().isRequired,
};
export default withI18n()(WorkflowNodeHelp);

View File

@ -1,14 +1,15 @@
import React from 'react';
import styled from 'styled-components';
import { shape } from 'prop-types';
import { PauseIcon } from '@patternfly/react-icons';
const NodeTypeLetter = styled.foreignObject`
font-size: 10px;
color: white;
text-align: center;
line-height: 20px;
background-color: #393f43;
border-radius: 50%;
color: white;
font-size: 10px;
line-height: 20px;
text-align: center;
`;
function WorkflowNodeTypeLetter({ node }) {
@ -52,4 +53,8 @@ function WorkflowNodeTypeLetter({ node }) {
);
}
WorkflowNodeTypeLetter.propTypes = {
node: shape().isRequired,
};
export default WorkflowNodeTypeLetter;

View File

@ -2,6 +2,7 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { func, number } from 'prop-types';
import { Tooltip } from '@patternfly/react-core';
import {
CaretDownIcon,
@ -15,19 +16,19 @@ import {
} from '@patternfly/react-icons';
const Wrapper = styled.div`
border: 1px solid #c7c7c7;
background-color: white;
border: 1px solid #c7c7c7;
height: 135px;
`;
const Header = styled.div`
padding: 10px;
border-bottom: 1px solid #c7c7c7;
padding: 10px;
`;
const Pan = styled.div`
display: flex;
align-items: center;
display: flex;
`;
const PanCenter = styled.div`
@ -36,18 +37,18 @@ const PanCenter = styled.div`
`;
const Tools = styled.div`
display: flex;
align-items: center;
display: flex;
padding: 20px;
`;
function VisualizerTools({
function WorkflowTools({
i18n,
zoomPercentage,
onZoomChange,
onFitGraph,
onPan,
onPanToMiddle,
onZoomChange,
zoomPercentage,
}) {
const zoomIn = () => {
const newScale =
@ -81,14 +82,16 @@ function VisualizerTools({
<MinusIcon onClick={() => zoomOut()} css="margin-right: 10px;" />
</Tooltip>
<input
type="range"
id="zoom-slider"
value={zoomPercentage}
min="10"
max="200"
min="10"
onChange={event =>
onZoomChange(parseInt(event.target.value, 10) / 100)
}
step="10"
onChange={event => onZoomChange(parseInt(event.target.value) / 100)}
></input>
type="range"
value={zoomPercentage}
/>
<Tooltip content={i18n._(t`Zoom In`)} position="bottom">
<PlusIcon onClick={() => zoomIn()} css="margin: 0px 25px 0px 10px;" />
</Tooltip>
@ -119,4 +122,12 @@ function VisualizerTools({
);
}
export default withI18n()(VisualizerTools);
WorkflowTools.propTypes = {
onFitGraph: func.isRequired,
onPan: func.isRequired,
onPanToMiddle: func.isRequired,
onZoomChange: func.isRequired,
zoomPercentage: number.isRequired,
};
export default withI18n()(WorkflowTools);

View File

@ -1,8 +1,10 @@
export { default as WorkflowHelp } from './WorkflowHelp';
export { default as WorkflowLinkHelp } from './WorkflowLinkHelp';
export { default as WorkflowNodeHelp } from './WorkflowNodeHelp';
export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter';
export { default as WorkflowActionTooltip } from './WorkflowActionTooltip';
export {
default as WorkflowActionTooltipItem,
} from './WorkflowActionTooltipItem';
export { default as WorkflowHelp } from './WorkflowHelp';
export { default as WorkflowKey } from './WorkflowKey';
export { default as WorkflowLinkHelp } from './WorkflowLinkHelp';
export { default as WorkflowNodeHelp } from './WorkflowNodeHelp';
export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter';
export { default as WorkflowTools } from './WorkflowTools';

View File

@ -25,16 +25,6 @@ import {
InventoriesAPI,
AdHocCommandsAPI,
} from '@api';
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 20px;
& > :not(:first-child) {
margin-left: 20px;
}
`;
const VariablesInput = styled(_VariablesInput)`
.pf-c-form__label {

View File

@ -2,64 +2,60 @@ import React, { useState, useEffect } 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 { layoutGraph } from '@util/workflow';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { WorkflowJobsAPI } from '@api';
import WorkflowOutputGraph from './WorkflowOutputGraph';
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
const CardBody = styled(PFCardBody)`
height: calc(100vh - 240px);
display: flex;
flex-direction: column;
`;
const Toolbar = styled.div`
height: 50px;
background-color: grey;
height: calc(100vh - 240px);
`;
const Wrapper = styled.div`
display: flex;
flex-flow: column;
height: 100%;
position: relative;
`;
const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
try {
const { data } = await WorkflowJobsAPI.readNodes(jobId, {
page_size: 200,
page: pageNo,
});
if (data.next) {
return await fetchWorkflowNodes(
jobId,
pageNo + 1,
nodes.concat(data.results)
);
}
return nodes.concat(data.results);
} catch (error) {
throw error;
const { data } = await WorkflowJobsAPI.readNodes(jobId, {
page_size: 200,
page: pageNo,
});
if (data.next) {
return fetchWorkflowNodes(
jobId,
pageNo + 1,
nodes.concat(data.results)
);
}
return nodes.concat(data.results);
};
function WorkflowOutput({ job, i18n }) {
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [graphLinks, setGraphLinks] = useState([]);
const [graphNodes, setGraphNodes] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [nodePositions, setNodePositions] = useState(null);
const [showKey, setShowKey] = useState(false);
const [showTools, setShowTools] = useState(false);
useEffect(() => {
const buildGraphArrays = nodes => {
const nonRootNodeIds = [];
const allNodeIds = [];
const arrayOfLinksForChart = [];
const nodeIdToChartNodeIdMapping = {};
const chartNodeIdToIndexMapping = {};
const nodeIdToChartNodeIdMapping = {};
const nodeRef = {};
const nonRootNodeIds = [];
let nodeIdCounter = 1;
const arrayOfNodesForChart = [
{
@ -110,7 +106,7 @@ function WorkflowOutput({ job, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'success',
linkType: 'success',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -121,7 +117,7 @@ function WorkflowOutput({ job, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'failure',
linkType: 'failure',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -132,7 +128,7 @@ function WorkflowOutput({ job, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'always',
linkType: 'always',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -151,7 +147,7 @@ function WorkflowOutput({ job, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[0],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'always',
linkType: 'always',
type: 'link',
});
});
@ -206,12 +202,21 @@ function WorkflowOutput({ job, i18n }) {
return (
<CardBody>
<Wrapper>
<Toolbar>Toolbar</Toolbar>
<WorkflowOutputToolbar
job={job}
keyShown={showKey}
nodes={graphNodes}
onKeyToggle={() => setShowKey(!showKey)}
onToolsToggle={() => setShowTools(!showTools)}
toolsShown={showTools}
/>
{nodePositions && (
<WorkflowOutputGraph
links={graphLinks}
nodes={graphNodes}
nodePositions={nodePositions}
nodes={graphNodes}
showKey={showKey}
showTools={showTools}
/>
)}
</Wrapper>
@ -219,4 +224,8 @@ function WorkflowOutput({ job, i18n }) {
);
}
WorkflowOutput.propTypes = {
job: shape().isRequired,
};
export default withI18n()(WorkflowOutput);

View File

@ -1,15 +1,28 @@
import React, { Fragment, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { WorkflowHelp, WorkflowNodeHelp } from '@components/Workflow';
import { calcZoomAndFit } from '@util/workflow';
import { arrayOf, bool, shape } from 'prop-types';
import { calcZoomAndFit, getZoomTranslate } from '@util/workflow';
import {
WorkflowOutputLink,
WorkflowOutputNode,
WorkflowOutputStartNode,
} from '@screens/Job/WorkflowOutput';
import {
WorkflowHelp,
WorkflowKey,
WorkflowNodeHelp,
WorkflowTools,
} from '@components/Workflow';
function WorkflowOutputGraph({ links, nodes, nodePositions }) {
function WorkflowOutputGraph({
links,
nodePositions,
nodes,
showKey,
showTools,
}) {
const [nodeHelp, setNodeHelp] = useState();
const [zoomPercentage, setZoomPercentage] = useState(100);
const svgRef = useRef(null);
const gRef = useRef(null);
@ -20,6 +33,75 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) {
'transform',
`translate(${translation}) scale(${d3.event.transform.k})`
);
setZoomPercentage(d3.event.transform.k * 100);
};
const handlePan = direction => {
const transform = d3.zoomTransform(d3.select(svgRef.current).node());
let { x: xPos, y: yPos } = transform;
const { k: currentScale } = transform;
switch (direction) {
case 'up':
yPos -= 50;
break;
case 'down':
yPos += 50;
break;
case 'left':
xPos -= 50;
break;
case 'right':
xPos += 50;
break;
default:
// Throw an error?
break;
}
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(xPos, yPos).scale(currentScale)
);
};
const handlePanToMiddle = () => {
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity
.translate(0, svgBoundingClientRect.height / 2 - 30)
.scale(1)
);
setZoomPercentage(100);
};
const handleZoomChange = newScale => {
const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(translateX, translateY).scale(newScale)
);
setZoomPercentage(newScale * 100);
};
const handleFitGraph = () => {
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
);
setZoomPercentage(scaleToFit * 100);
};
const zoomRef = d3
@ -34,12 +116,17 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) {
// Attempt to zoom the graph to fit the available screen space
useEffect(() => {
const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current);
const [scaleToFit, yTranslate] = calcZoomAndFit(
gRef.current,
svgRef.current
);
d3.select(svgRef.current).call(
zoomRef.transform,
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
);
setZoomPercentage(scaleToFit * 100);
// We only want this to run once (when the component mounts)
// Including zoomRef.transform in the deps array will cause this to
// run very frequently.
@ -78,10 +165,10 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) {
return (
<WorkflowOutputNode
key={`node-${node.id}`}
node={node}
nodePositions={nodePositions}
mouseEnter={() => setNodeHelp(node)}
mouseLeave={() => setNodeHelp(null)}
node={node}
nodePositions={nodePositions}
/>
);
}
@ -90,8 +177,28 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) {
]}
</g>
</svg>
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && (
<WorkflowTools
onFitGraph={handleFitGraph}
onPan={handlePan}
onPanToMiddle={handlePanToMiddle}
onZoomChange={handleZoomChange}
zoomPercentage={zoomPercentage}
/>
)}
{showKey && <WorkflowKey />}
</div>
</Fragment>
);
}
WorkflowOutputGraph.propTypes = {
links: arrayOf(shape()).isRequired,
nodePositions: shape().isRequired,
nodes: arrayOf(shape()).isRequired,
showKey: bool.isRequired,
showTools: bool.isRequired,
};
export default WorkflowOutputGraph;

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { shape } from 'prop-types';
import { generateLine, getLinePoints } from '@util/workflow';
function WorkflowOutputLink({ link, nodePositions }) {
@ -6,16 +7,16 @@ function WorkflowOutputLink({ link, nodePositions }) {
const [pathStroke, setPathStroke] = useState('#CCCCCC');
useEffect(() => {
if (link.edgeType === 'failure') {
if (link.linkType === 'failure') {
setPathStroke('#d9534f');
}
if (link.edgeType === 'success') {
if (link.linkType === 'success') {
setPathStroke('#5cb85c');
}
if (link.edgeType === 'always') {
if (link.linkType === 'always') {
setPathStroke('#337ab7');
}
}, [link.edgeType]);
}, [link.linkType]);
useEffect(() => {
const linePoints = getLinePoints(link, nodePositions);
@ -37,4 +38,9 @@ function WorkflowOutputLink({ link, nodePositions }) {
);
}
WorkflowOutputLink.propTypes = {
link: shape().isRequired,
nodePositions: shape().isRequired,
};
export default WorkflowOutputLink;

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { func, shape } from 'prop-types';
import { StatusIcon } from '@components/Sparkline';
import { WorkflowNodeTypeLetter } from '@components/Workflow';
import { secondsToHHMMSS } from '@util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
import { constants as wfConstants } from '@util/workflow';
const NodeG = styled.g`
@ -13,12 +14,12 @@ const NodeG = styled.g`
`;
const JobTopLine = styled.div`
display: flex;
align-items: center;
display: flex;
margin-top: 5px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
p {
margin-left: 10px;
@ -29,8 +30,8 @@ const JobTopLine = styled.div`
`;
const Elapsed = styled.div`
text-align: center;
margin-top: 5px;
text-align: center;
span {
font-size: 12px;
@ -48,18 +49,19 @@ const NodeContents = styled.foreignObject`
const NodeDefaultLabel = styled.p`
margin-top: 20px;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
`;
function WorkflowOutputNode({
node,
nodePositions,
history,
i18n,
mouseEnter,
mouseLeave,
i18n,
node,
nodePositions,
}) {
let borderColor = '#93969A';
@ -78,10 +80,7 @@ function WorkflowOutputNode({
const handleNodeClick = () => {
if (node.job) {
window.open(
`/#/jobs/${JOB_TYPE_URL_SEGMENTS[node.job.type]}/${node.job.id}`,
'_blank'
);
history.push(`/jobs/${node.job.id}/details`);
}
};
@ -96,13 +95,13 @@ function WorkflowOutputNode({
onMouseLeave={mouseLeave}
>
<rect
width={wfConstants.nodeW}
fill="#FFFFFF"
height={wfConstants.nodeH}
rx="2"
ry="2"
stroke={borderColor}
strokeWidth="2px"
fill="#FFFFFF"
width={wfConstants.nodeW}
/>
<NodeContents height="60" width="180">
{node.job ? (
@ -133,4 +132,11 @@ function WorkflowOutputNode({
);
}
export default withI18n()(WorkflowOutputNode);
WorkflowOutputNode.propTypes = {
mouseEnter: func.isRequired,
mouseLeave: func.isRequired,
node: shape().isRequired,
nodePositions: shape().isRequired,
};
export default withI18n()(withRouter(WorkflowOutputNode));

View File

@ -1,18 +1,19 @@
import React from 'react';
import { shape } from 'prop-types';
import { constants as wfConstants } from '@util/workflow';
function WorkflowOutputStartNode({ nodePositions }) {
return (
<g id="node-1" transform={`translate(${nodePositions[1].x},0)`}>
<rect
width={wfConstants.rootW}
fill="#0279BC"
height={wfConstants.rootH}
y="10"
rx="2"
ry="2"
fill="#0279BC"
width={wfConstants.rootW}
y="10"
/>
{/* TODO: Translate this...? */}
{/* TODO: We need to be able to handle translated text here */}
<text x="13" y="30" dy=".35em" fill="white">
START
</text>
@ -20,4 +21,8 @@ function WorkflowOutputStartNode({ nodePositions }) {
);
}
WorkflowOutputStartNode.propTypes = {
nodePositions: shape().isRequired,
};
export default WorkflowOutputStartNode;

View File

@ -0,0 +1,90 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { arrayOf, bool, func, 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';
import VerticalSeparator from '@components/VerticalSeparator';
import styled from 'styled-components';
const Badge = styled(PFBadge)`
align-items: center;
display: flex;
justify-content: center;
margin-left: 10px;
`;
const ActionButton = styled(Button)`
border: none;
margin: 0px 6px;
padding: 6px 10px;
&:hover {
background-color: #0066cc;
color: white;
}
&.pf-m-active {
background-color: #0066cc;
color: white;
}
`;
function WorkflowOutputToolbar({
i18n,
job,
keyShown,
nodes,
onKeyToggle,
onToolsToggle,
toolsShown,
}) {
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return (
<div css="border-bottom: 1px solid grey; height: 56px; display: flex; alignItems: center">
<div css="display: flex; align-items: center;">
<StatusIcon status={job.status} css="margin-right: 20px" />
<b>{job.name}</b>
</div>
<div css="display: flex; flex: 1; justify-content: flex-end; align-items: center;">
<div>{i18n._(t`Total Nodes`)}</div>
<Badge isRead>{totalNodes}</Badge>
<VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Key`)} position="bottom">
<ActionButton
isActive={keyShown}
onClick={onKeyToggle}
variant="plain"
>
<CompassIcon />
</ActionButton>
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
isActive={toolsShown}
onClick={onToolsToggle}
variant="plain"
>
<WrenchIcon />
</ActionButton>
</Tooltip>
</div>
</div>
);
}
WorkflowOutputToolbar.propTypes = {
job: shape().isRequired,
keyShown: bool.isRequired,
nodes: arrayOf(shape()),
onKeyToggle: func.isRequired,
onToolsToggle: func.isRequired,
toolsShown: bool.isRequired,
};
WorkflowOutputToolbar.defaultProps = {
nodes: [],
};
export default withI18n()(WorkflowOutputToolbar);

View File

@ -3,3 +3,4 @@ export { default as WorkflowOutputGraph } from './WorkflowOutputGraph';
export { default as WorkflowOutputLink } from './WorkflowOutputLink';
export { default as WorkflowOutputNode } from './WorkflowOutputNode';
export { default as WorkflowOutputStartNode } from './WorkflowOutputStartNode';
export { default as WorkflowOutputToolbar } from './WorkflowOutputToolbar';

View File

@ -1,5 +1,4 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import Templates from './Templates';

View File

@ -2,15 +2,12 @@ import React from 'react';
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 }) {
return (
<AlertModal
variant="danger"
title={i18n._(t`Remove All Nodes`)}
isOpen={true}
onClose={onCancel}
actions={[
<Button
key="remove"
@ -29,6 +26,10 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
{i18n._(t`Cancel`)}
</Button>,
]}
isOpen
onClose={onCancel}
title={i18n._(t`Remove All Nodes`)}
variant="danger"
>
<p>
{i18n._(
@ -39,4 +40,9 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) {
);
}
DeleteAllNodesModal.propTypes = {
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(DeleteAllNodesModal);

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
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 }) {
@ -13,18 +14,18 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
onClose={onCancel}
actions={[
<Button
key="remove"
variant="danger"
aria-label={i18n._(t`Confirm link removal`)}
key="remove"
onClick={() => onConfirm()}
variant="danger"
>
{i18n._(t`Remove`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel link removal`)}
key="cancel"
onClick={onCancel}
variant="secondary"
>
{i18n._(t`Cancel`)}
</Button>,
@ -45,4 +46,10 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
);
}
LinkDeleteModal.propTypes = {
linkToDelete: shape().isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(LinkDeleteModal);

View File

@ -1,23 +1,17 @@
import React, { useState } from 'react';
import { Button, Modal } from '@patternfly/react-core';
import { Button, FormGroup, Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { func, node, string } from 'prop-types';
import AnsibleSelect from '@components/AnsibleSelect';
function LinkModal({
i18n,
header,
onCancel,
onConfirm,
edgeType = 'success',
}) {
const [newEdgeType, setNewEdgeType] = useState(edgeType);
function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) {
const [newLinkType, setNewLinkType] = useState(linkType);
return (
<Modal
width={600}
header={header}
isOpen={true}
isOpen
title={i18n._(t`Workflow Link`)}
onClose={onCancel}
actions={[
@ -25,7 +19,7 @@ function LinkModal({
key="save"
variant="primary"
aria-label={i18n._(t`Save link changes`)}
onClick={() => onConfirm(newEdgeType)}
onClick={() => onConfirm(newLinkType)}
>
{i18n._(t`Save`)}
</Button>,
@ -42,7 +36,7 @@ function LinkModal({
<FormGroup fieldId="link-select" label={i18n._(t`Run`)}>
<AnsibleSelect
id="link-select"
value={newEdgeType}
value={newLinkType}
data={[
{
value: 'always',
@ -61,7 +55,7 @@ function LinkModal({
},
]}
onChange={(event, value) => {
setNewEdgeType(value);
setNewLinkType(value);
}}
/>
</FormGroup>
@ -69,4 +63,15 @@ function LinkModal({
);
}
LinkModal.propTypes = {
linkType: string,
header: node.isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
LinkModal.defaultProps = {
linkType: 'success',
};
export default withI18n()(LinkModal);

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
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 }) {
@ -45,4 +46,10 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
);
}
NodeDeleteModal.propTypes = {
nodeToDelete: shape().isRequired,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
export default withI18n()(NodeDeleteModal);

View File

@ -2,82 +2,84 @@ import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { bool, func, node, shape } from 'prop-types';
import {
Button,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import NodeTypeStep from './NodeTypeStep/NodeTypeStep';
import RunStep from './RunStep';
import NodeNextButton from './NodeNextButton';
import { Wizard } from '@components/Wizard';
import Wizard from '@components/Wizard';
import { NodeTypeStep } from './NodeTypeStep';
import { RunStep, NodeNextButton } from '.';
function NodeModal({
askLinkType,
history,
i18n,
title,
nodeToEdit,
onClose,
onSave,
node,
askLinkType,
title,
}) {
let defaultNodeType = 'job_template';
let defaultNodeResource = null;
let defaultApprovalName = '';
let defaultApprovalDescription = '';
let defaultApprovalName = '';
let defaultApprovalTimeout = 0;
if (node && node.unifiedJobTemplate) {
let defaultNodeResource = null;
let defaultNodeType = 'job_template';
if (nodeToEdit && nodeToEdit.unifiedJobTemplate) {
if (
node &&
node.unifiedJobTemplate &&
(node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type)
nodeToEdit &&
nodeToEdit.unifiedJobTemplate &&
(nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type)
) {
const ujtType =
node.unifiedJobTemplate.type ||
node.unifiedJobTemplate.unified_job_type;
nodeToEdit.unifiedJobTemplate.type ||
nodeToEdit.unifiedJobTemplate.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
defaultNodeType = 'job_template';
defaultNodeResource = node.unifiedJobTemplate;
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'project':
case 'project_update':
defaultNodeType = 'project_sync';
defaultNodeResource = node.unifiedJobTemplate;
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'inventory_source':
case 'inventory_update':
defaultNodeType = 'inventory_source_sync';
defaultNodeResource = node.unifiedJobTemplate;
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_job_template':
case 'workflow_job':
defaultNodeType = 'workflow_job_template';
defaultNodeResource = node.unifiedJobTemplate;
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
break;
case 'workflow_approval_template':
case 'workflow_approval':
defaultNodeType = 'approval';
defaultApprovalName = node.unifiedJobTemplate.name;
defaultApprovalDescription = node.unifiedJobTemplate.description;
defaultApprovalTimeout = node.unifiedJobTemplate.timeout;
defaultApprovalName = nodeToEdit.unifiedJobTemplate.name;
defaultApprovalDescription =
nodeToEdit.unifiedJobTemplate.description;
defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout;
break;
default:
}
}
}
const [nodeType, setNodeType] = useState(defaultNodeType);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [triggerNext, setTriggerNext] = useState(0);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalDescription, setApprovalDescription] = useState(
defaultApprovalDescription
);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalTimeout, setApprovalTimeout] = useState(
defaultApprovalTimeout
);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [nodeType, setNodeType] = useState(defaultNodeType);
const [triggerNext, setTriggerNext] = useState(0);
const clearQueryParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&');
@ -95,19 +97,17 @@ function NodeModal({
const resource =
nodeType === 'approval'
? {
name: approvalName,
description: approvalDescription,
name: approvalName,
timeout: approvalTimeout,
type: 'workflow_approval_template',
}
: nodeResource;
// TODO: pick edgeType or linkType and be consistent across all files.
onSave({
nodeType,
edgeType: linkType,
linkType,
nodeResource: resource,
nodeType,
});
};
@ -145,15 +145,15 @@ function NodeModal({
(nodeType === 'approval' && approvalName !== ''),
component: (
<NodeTypeStep
nodeType={nodeType}
updateNodeType={handleNodeTypeChange}
nodeResource={nodeResource}
updateNodeResource={setNodeResource}
name={approvalName}
updateName={setApprovalName}
description={approvalDescription}
updateDescription={setApprovalDescription}
name={approvalName}
nodeResource={nodeResource}
nodeType={nodeType}
timeout={approvalTimeout}
updateDescription={setApprovalDescription}
updateName={setApprovalName}
updateNodeResource={setNodeResource}
updateNodeType={handleNodeTypeChange}
updateTimeout={setApprovalTimeout}
/>
),
@ -198,15 +198,27 @@ function NodeModal({
return (
<Wizard
style={{ overflow: 'scroll' }}
footer={CustomFooter}
isOpen
steps={steps}
title={wizardTitle}
onClose={handleCancel}
onSave={handleSaveNode}
footer={CustomFooter}
steps={steps}
css="overflow: scroll"
title={wizardTitle}
/>
);
}
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

@ -1,22 +1,20 @@
import React, { useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, number, shape, string } from 'prop-types';
import { Button } from '@patternfly/react-core';
function NodeNextButton({
i18n,
activeStep,
buttonText,
onClick,
onNext,
triggerNext,
onClick,
buttonText,
}) {
useEffect(() => {
if (!triggerNext) {
return;
}
onNext();
}, [triggerNext]);
}, [onNext, triggerNext]);
return (
<Button
@ -30,4 +28,12 @@ function NodeNextButton({
);
}
export default withI18n()(NodeNextButton);
NodeNextButton.propTypes = {
activeStep: shape().isRequired,
buttonText: string.isRequired,
onClick: func.isRequired,
onNext: func.isRequired,
triggerNext: number.isRequired,
};
export default NodeNextButton;

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { InventorySourcesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
@ -15,14 +16,14 @@ const QS_CONFIG = getQSConfig('inventory_sources', {
});
function InventorySourcesList({
i18n,
history,
i18n,
nodeResource,
updateNodeResource,
}) {
const [inventorySources, setInventorySources] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [inventorySources, setInventorySources] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
@ -47,9 +48,24 @@ function InventorySourcesList({
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={inventorySources}
itemCount={count}
items={inventorySources}
onRowClick={row => updateNodeResource(row)}
qsConfig={QS_CONFIG}
showPageSizeOptions={false}
renderItem={item => (
<CheckboxListItem
isSelected={!!(nodeResource && nodeResource.id === item.id)}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
toolbarColumns={[
{
name: i18n._(t`Name`),
@ -58,24 +74,17 @@ function InventorySourcesList({
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
InventorySourcesList.propTypes = {
nodeResource: shape(),
updateNodeResource: func.isRequired,
};
InventorySourcesList.defaultProps = {
nodeResource: null,
};
export default withI18n()(withRouter(InventorySourcesList));

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { JobTemplatesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
@ -15,10 +16,10 @@ const QS_CONFIG = getQSConfig('job_templates', {
});
function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) {
const [jobTemplates, setJobTemplates] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [jobTemplates, setJobTemplates] = useState([]);
useEffect(() => {
(async () => {
@ -44,9 +45,24 @@ function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) {
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={jobTemplates}
itemCount={count}
items={jobTemplates}
onRowClick={row => updateNodeResource(row)}
qsConfig={QS_CONFIG}
renderItem={item => (
<CheckboxListItem
isSelected={!!(nodeResource && nodeResource.id === item.id)}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
toolbarColumns={[
{
name: i18n._(t`Name`),
@ -55,24 +71,17 @@ function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) {
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
JobTemplatesList.propTypes = {
nodeResource: shape(),
updateNodeResource: func.isRequired,
};
JobTemplatesList.defaultProps = {
nodeResource: null,
};
export default withI18n()(withRouter(JobTemplatesList));

View File

@ -1,19 +1,25 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, number, shape, string } from 'prop-types';
import styled from 'styled-components';
import { Formik, Field } from 'formik';
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
import { Divider } from '@patternfly/react-core/dist/esm/experimental';
import FormRow from '@components/FormRow';
import AnsibleSelect from '@components/AnsibleSelect';
import VerticalSeperator from '@components/VerticalSeparator';
import InventorySourcesList from './InventorySourcesList';
import JobTemplatesList from './JobTemplatesList';
import ProjectsList from './ProjectsList';
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
const Divider = styled.div`
height: 1px;
background-color: var(--pf-global--Color--light-300);
border: 0;
flex-shrink: 0;
`;
const TimeoutInput = styled(TextInput)`
width: 200px;
:not(:first-of-type) {
@ -26,16 +32,16 @@ const TimeoutLabel = styled.p`
`;
function NodeTypeStep({
i18n,
nodeType = 'job_template',
updateNodeType,
nodeResource,
updateNodeResource,
name,
updateName,
description,
i18n,
name,
nodeResource,
nodeType,
timeout,
updateDescription,
timeout = 0,
updateName,
updateNodeResource,
updateNodeType,
updateTimeout,
}) {
return (
@ -132,21 +138,21 @@ function NodeTypeStep({
return (
<FormGroup
fieldId="approval-name"
isRequired={true}
isRequired
isValid={isValid}
label={i18n._(t`Name`)}
>
<TextInput
autoFocus
id="approval-name"
isRequired={true}
isRequired
isValid={isValid}
type="text"
{...field}
onChange={(value, event) => {
onChange={(value, evt) => {
updateName(value);
field.onChange(event);
field.onChange(evt);
}}
autoFocus
/>
</FormGroup>
);
@ -165,9 +171,9 @@ function NodeTypeStep({
id="approval-description"
type="text"
{...field}
onChange={value => {
onChange={(value, evt) => {
updateDescription(value);
field.onChange(event);
field.onChange(evt);
}}
/>
</FormGroup>
@ -190,7 +196,7 @@ function NodeTypeStep({
min="0"
step="1"
{...field}
onChange={value => {
onChange={(value, evt) => {
if (!value || value === '') {
value = 0;
}
@ -198,7 +204,7 @@ function NodeTypeStep({
Number(value) * 60 +
Number(form.values.timeoutSeconds)
);
field.onChange(event);
field.onChange(evt);
}}
/>
<TimeoutLabel>min</TimeoutLabel>
@ -215,7 +221,7 @@ function NodeTypeStep({
min="0"
step="1"
{...field}
onChange={value => {
onChange={(value, evt) => {
if (!value || value === '') {
value = 0;
}
@ -223,7 +229,7 @@ function NodeTypeStep({
Number(value) +
Number(form.values.timeoutMinutes) * 60
);
field.onChange(event);
field.onChange(evt);
}}
/>
<TimeoutLabel>sec</TimeoutLabel>
@ -241,4 +247,25 @@ function NodeTypeStep({
);
}
NodeTypeStep.propTypes = {
description: string,
name: string,
nodeResource: shape(),
nodeType: string,
timeout: number,
updateDescription: func.isRequired,
updateName: func.isRequired,
updateNodeResource: func.isRequired,
updateNodeType: func.isRequired,
updateTimeout: func.isRequired,
};
NodeTypeStep.defaultProps = {
description: '',
name: '',
nodeResource: null,
nodeType: 'job_template',
timeout: 0,
};
export default withI18n()(NodeTypeStep);

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { ProjectsAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
@ -14,11 +15,11 @@ const QS_CONFIG = getQSConfig('projects', {
order_by: 'name',
});
function ProjectsList({ i18n, history, nodeResource, updateNodeResource }) {
const [projects, setProjects] = useState([]);
function ProjectsList({ history, i18n, nodeResource, updateNodeResource }) {
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState([]);
useEffect(() => {
(async () => {
@ -42,9 +43,24 @@ function ProjectsList({ i18n, history, nodeResource, updateNodeResource }) {
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={projects}
itemCount={count}
items={projects}
onRowClick={row => updateNodeResource(row)}
qsConfig={QS_CONFIG}
renderItem={item => (
<CheckboxListItem
isSelected={!!(nodeResource && nodeResource.id === item.id)}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
toolbarColumns={[
{
name: i18n._(t`Name`),
@ -53,24 +69,17 @@ function ProjectsList({ i18n, history, nodeResource, updateNodeResource }) {
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
ProjectsList.propTypes = {
nodeResource: shape(),
updateNodeResource: func.isRequired,
};
ProjectsList.defaultProps = {
nodeResource: null,
};
export default withI18n()(withRouter(ProjectsList));

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { WorkflowJobTemplatesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
@ -15,15 +16,15 @@ const QS_CONFIG = getQSConfig('workflow_job_templates', {
});
function WorkflowJobTemplatesList({
i18n,
history,
i18n,
nodeResource,
updateNodeResource,
}) {
const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
useEffect(() => {
(async () => {
@ -49,9 +50,24 @@ function WorkflowJobTemplatesList({
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={workflowJobTemplates}
itemCount={count}
items={workflowJobTemplates}
onRowClick={row => updateNodeResource(row)}
qsConfig={QS_CONFIG}
renderItem={item => (
<CheckboxListItem
isSelected={!!(nodeResource && nodeResource.id === item.id)}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
toolbarColumns={[
{
name: i18n._(t`Name`),
@ -60,24 +76,17 @@ function WorkflowJobTemplatesList({
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
WorkflowJobTemplatesList.propTypes = {
nodeResource: shape(),
updateNodeResource: func.isRequired,
};
WorkflowJobTemplatesList.defaultProps = {
nodeResource: null,
};
export default withI18n()(withRouter(WorkflowJobTemplatesList));

View File

@ -0,0 +1,7 @@
export { default as InventorySourcesList } from './InventorySourcesList';
export { default as JobTemplatesList } from './JobTemplatesList';
export { default as NodeTypeStep } from './NodeTypeStep';
export { default as ProjectsList } from './ProjectsList';
export {
default as WorkflowJobTemplatesList,
} from './WorkflowJobTemplatesList';

View File

@ -2,17 +2,18 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { func, string } from 'prop-types';
import { Title } from '@patternfly/react-core';
import { SelectableCard } from '@components/SelectableCard';
import SelectableCard from '@components/SelectableCard';
const Grid = styled.div`
display: grid;
grid-template-columns: 33% 33% 33%;
grid-gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 100px;
width: 100%;
grid-gap: 20px;
grid-template-columns: 33% 33% 33%;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
margin: 20px 0px;
width: 100%;
`;
function RunStep({ i18n, linkType, updateLinkType }) {
@ -56,4 +57,9 @@ function RunStep({ i18n, linkType, updateLinkType }) {
);
}
RunStep.propTypes = {
linkType: string.isRequired,
updateLinkType: func.isRequired,
};
export default withI18n()(RunStep);

View File

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

View File

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

View File

@ -1,26 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { DetailList, Detail } from '@components/DetailList';
function ApprovalDetails({ i18n, node }) {
const { name, description, timeout } = node.unifiedJobTemplate;
let timeoutValue = i18n._(t`None`);
if (timeout) {
const minutes = Math.floor(timeout / 60);
const seconds = timeout - minutes * 60;
timeoutValue = i18n._(t`${minutes}min ${seconds}sec`);
}
return (
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Approval`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Timeout`)} value={timeoutValue} />
</DetailList>
);
}
export default withI18n()(ApprovalDetails);

View File

@ -1,165 +0,0 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { InventorySourcesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput';
import { CredentialChip } from '@components/Chip';
function InventorySourceSyncDetails({ i18n, node }) {
const [inventorySource, setInventorySource] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
const [optionsActions, setOptionsActions] = useState(null);
useEffect(() => {
async function fetchInventorySource() {
try {
const [
{ data },
{
data: { actions },
},
] = await Promise.all([
InventorySourcesAPI.readDetail(node.unifiedJobTemplate.id),
InventorySourcesAPI.readOptions(),
]);
setInventorySource(data);
setOptionsActions(actions);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchInventorySource();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this inventory source so
the displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Inventory Source Sync`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
custom_virtualenv,
description,
group_by,
instance_filters,
name,
source,
source_path,
source_regions,
source_script,
source_vars,
summary_fields,
timeout,
verbosity,
} = inventorySource;
let sourceValue = '';
let verbosityValue = '';
optionsActions.GET.source.choices.forEach(choice => {
if (choice[0] === source) {
sourceValue = choice[1];
}
});
optionsActions.GET.verbosity.choices.forEach(choice => {
if (choice[0] === verbosity) {
verbosityValue = choice[1];
}
});
return (
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Inventory Source Sync`)}
/>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={summary_fields.inventory.name}
/>
)}
{summary_fields.credential && (
<Detail
label={i18n._(t`Credential`)}
value={
<CredentialChip
key={summary_fields.credential.id}
credential={summary_fields.credential}
isReadOnly
/>
}
/>
)}
<Detail label={i18n._(t`Source`)} value={sourceValue} />
<Detail label={i18n._(t`Source Path`)} value={source_path} />
<Detail label={i18n._(t`Source Script`)} value={source_script} />
{/* this should probably be tags built from OPTIONS*/}
<Detail label={i18n._(t`Source Regions`)} value={source_regions} />
<Detail label={i18n._(t`Instance Filters`)} value={instance_filters} />
{/* this should probably be tags built from OPTIONS */}
<Detail label={i18n._(t`Only Group By`)} value={group_by} />
<Detail
label={i18n._(t`Timeout`)}
value={`${timeout} ${i18n._(t`Seconds`)}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Detail label={i18n._(t`Verbosity`)} value={verbosityValue} />
<VariablesDetail
label={i18n._(t`Variables`)}
value={source_vars}
rows={4}
/>
</DetailList>
);
}
export default withI18n()(InventorySourceSyncDetails);

View File

@ -1,564 +0,0 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import jsyaml from 'js-yaml';
import styled from 'styled-components';
import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesDetail } from '@components/CodeMirrorInput';
const Overridden = styled.div`
color: var(--pf-global--warning-color--100);
`;
function JobTemplateDetails({ i18n, node }) {
const [jobTemplate, setJobTemplate] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
const [optionsActions, setOptionsActions] = useState(null);
const [instanceGroups, setInstanceGroups] = useState([]);
const [nodeCredentials, setNodeCredentials] = useState([]);
const [launchConf, setLaunchConf] = useState(null);
useEffect(() => {
async function fetchJobTemplate() {
try {
const [
{ data },
{
data: { results: instanceGroups },
},
{ data: launchConf },
{
data: { actions },
},
{
data: { results: nodeCredentials },
},
] = await Promise.all([
JobTemplatesAPI.readDetail(node.unifiedJobTemplate.id),
JobTemplatesAPI.readInstanceGroups(node.unifiedJobTemplate.id),
JobTemplatesAPI.readLaunch(node.unifiedJobTemplate.id),
JobTemplatesAPI.readOptions(),
WorkflowJobTemplateNodesAPI.readCredentials(
node.originalNodeObject.id
),
]);
setJobTemplate(data);
setInstanceGroups(instanceGroups);
setLaunchConf(launchConf);
setOptionsActions(actions);
setNodeCredentials(nodeCredentials);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchJobTemplate();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this job template so the
displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Job Template`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
job_type: nodeJobType,
limit: nodeLimit,
scm_branch: nodeScmBranch,
inventory: nodeInventory,
verbosity: nodeVerbosity,
job_tags: nodeJobTags,
skip_tags: nodeSkipTags,
diff_mode: nodeDiffMode,
extra_data: nodeExtraData,
summary_fields: nodeSummaryFields,
} = node.originalNodeObject;
let {
ask_job_type_on_launch,
ask_limit_on_launch,
ask_scm_branch_on_launch,
ask_inventory_on_launch,
ask_verbosity_on_launch,
ask_tags_on_launch,
ask_skip_tags_on_launch,
ask_diff_mode_on_launch,
ask_credential_on_launch,
ask_variables_on_launch,
description,
diff_mode,
extra_vars,
forks,
host_config_key,
job_slice_count,
job_tags,
job_type,
name,
limit,
playbook,
skip_tags,
timeout,
summary_fields,
verbosity,
scm_branch,
inventory,
} = jobTemplate;
const jobTypeOverridden =
ask_job_type_on_launch && nodeJobType !== null && job_type !== nodeJobType;
const limitOverridden =
ask_limit_on_launch && nodeLimit !== null && limit !== nodeLimit;
const scmBranchOverridden =
ask_scm_branch_on_launch &&
nodeScmBranch !== null &&
scm_branch !== nodeScmBranch;
const inventoryOverridden =
ask_inventory_on_launch &&
nodeInventory !== null &&
inventory !== nodeInventory;
const verbosityOverridden =
ask_verbosity_on_launch &&
nodeVerbosity !== null &&
verbosity !== nodeVerbosity;
const jobTagsOverridden =
ask_tags_on_launch && nodeJobTags !== null && job_tags !== nodeJobTags;
const skipTagsOverridden =
ask_skip_tags_on_launch &&
nodeSkipTags !== null &&
skip_tags !== nodeSkipTags;
const diffModeOverridden =
ask_diff_mode_on_launch &&
nodeDiffMode !== null &&
diff_mode !== nodeDiffMode;
const credentialOverridden =
ask_credential_on_launch && nodeCredentials.length > 0;
let variablesOverridden = false;
let variablesToShow = extra_vars;
const deepObjectMatch = (obj1, obj2) => {
if (obj1 === obj2) {
return true;
}
if (
obj1 === null ||
obj2 === null ||
typeof obj1 !== 'object' ||
typeof obj2 !== 'object'
) {
return false;
}
const obj1Keys = Object.keys(obj1);
const obj2Keys = Object.keys(obj2);
if (obj1Keys.length !== obj2Keys.length) {
return false;
}
for (let key of obj1Keys) {
if (!obj2Keys.includes(key) || !deepObjectMatch(obj1[key], obj2[key])) {
return false;
}
}
return true;
};
if (ask_variables_on_launch || launchConf.survey_enabled) {
// we need to check to see if the extra vars are different from the defaults
// but we'll need to do some normalization. Convert both to JSON objects
// and then compare.
let jsonifiedExtraVars = {};
let jsonifiedExtraData = {};
// extra_vars has to be a string
if (typeof extra_vars === 'string') {
if (
extra_vars === '{}' ||
extra_vars === 'null' ||
extra_vars === '' ||
extra_vars === '""'
) {
jsonifiedExtraVars = {};
} else {
try {
// try to turn the string into json
jsonifiedExtraVars = JSON.parse(extra_vars);
} catch (jsonParseError) {
try {
// do safeLoad, which well error if not valid yaml
jsonifiedExtraVars = jsyaml.safeLoad(extra_vars);
} catch (yamlLoadError) {
setContentError(yamlLoadError);
}
}
}
} else {
setContentError(
Error(i18n._(t`Error parsing extra variables from the job template`))
);
}
// extra_data on a node can be either a string or an object...
if (typeof nodeExtraData === 'string') {
if (
nodeExtraData === '{}' ||
nodeExtraData === 'null' ||
nodeExtraData === '' ||
nodeExtraData === '""'
) {
jsonifiedExtraData = {};
} else {
try {
// try to turn the string into json
jsonifiedExtraData = JSON.parse(nodeExtraData);
} catch (error) {
try {
// do safeLoad, which well error if not valid yaml
jsonifiedExtraData = jsyaml.safeLoad(nodeExtraData);
} catch (yamlLoadError) {
setContentError(yamlLoadError);
}
}
}
} else if (typeof nodeExtraData === 'object') {
jsonifiedExtraData = nodeExtraData;
} else {
setContentError(
Error(i18n._(t`Error parsing extra variables from the node`))
);
}
if (!deepObjectMatch(jsonifiedExtraVars, jsonifiedExtraData)) {
variablesOverridden = true;
variablesToShow = jsyaml.safeDump(
Object.assign(jsonifiedExtraVars, jsonifiedExtraData)
);
}
}
let credentialsToShow = summary_fields.credentials;
if (credentialOverridden) {
credentialsToShow = [...nodeCredentials];
// adds vault_id to the credentials we get back from
// fetching the JT
launchConf.defaults.credentials.forEach(launchCred => {
if (launchCred.vault_id) {
summary_fields.credentials[
summary_fields.credentials.findIndex(
defaultCred => defaultCred.id === launchCred.id
)
].vault_id = launchCred.vault_id;
}
});
summary_fields.credentials.forEach(defaultCred => {
if (
!nodeCredentials.some(
overrideCredential =>
(defaultCred.kind === overrideCredential.kind &&
(!defaultCred.vault_id && !overrideCredential.inputs.vault_id)) ||
(defaultCred.vault_id &&
overrideCredential.inputs.vault_id &&
defaultCred.vault_id === overrideCredential.inputs.vault_id)
)
) {
credentialsToShow.push(defaultCred);
}
});
}
let verbosityToShow = '';
optionsActions.GET.verbosity.choices.forEach(choice => {
if (
verbosityOverridden
? choice[0] === nodeVerbosity
: choice[0] === verbosity
) {
verbosityToShow = choice[1];
}
});
const jobTagsToShow = jobTagsOverridden ? nodeJobTags : job_tags;
const skipTagsToShow = skipTagsOverridden ? nodeSkipTags : skip_tags;
return (
<>
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Job Template`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail
label={
jobTypeOverridden ? (
<Overridden>* {i18n._(t`Job Type`)}</Overridden>
) : (
i18n._(t`Job Type`)
)
}
value={jobTypeOverridden ? nodeJobType : job_type}
/>
<Detail
label={
inventoryOverridden ? (
<Overridden>* {i18n._(t`Inventory`)}</Overridden>
) : (
i18n._(t`Inventory`)
)
}
value={
inventoryOverridden
? nodeSummaryFields.inventory.name
: summary_fields.inventory.name
}
alwaysVisible={inventoryOverridden}
/>
{summary_fields.project && (
<Detail
label={i18n._(t`Project`)}
value={summary_fields.project.name}
/>
)}
<Detail
label={
scmBranchOverridden ? (
<Overridden>* {i18n._(t`SCM Branch`)}</Overridden>
) : (
i18n._(t`SCM Branch`)
)
}
value={scmBranchOverridden ? nodeScmBranch : scm_branch}
alwaysVisible={scmBranchOverridden}
/>
<Detail label={i18n._(t`Playbook`)} value={playbook} />
<Detail label={i18n._(t`Forks`)} value={forks || '0'} />
<Detail
label={
limitOverridden ? (
<Overridden>* {i18n._(t`Limit`)}</Overridden>
) : (
i18n._(t`Limit`)
)
}
value={limitOverridden ? nodeLimit : limit}
alwaysVisible={limitOverridden}
/>
<Detail
label={
verbosityOverridden ? (
<Overridden>* {i18n._(t`Verbosity`)}</Overridden>
) : (
i18n._(t`Verbosity`)
)
}
value={verbosityToShow}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
<Detail
label={
diffModeOverridden ? (
<Overridden>* {i18n._(t`Show Changes`)}</Overridden>
) : (
i18n._(t`Show Changes`)
)
}
value={
(diffModeOverridden
? nodeDiffMode
: diff_mode)
? i18n._(t`On`)
: i18n._(t`Off`)
}
/>
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
{host_config_key && (
<>
<Detail
label={i18n._(t`Host Config Key`)}
value={host_config_key}
/>
<Detail
label={i18n._(t`Provisioning Callback URL`)}
value={generateCallBackUrl}
/>
</>
)}
<Detail
fullWidth
label={
credentialOverridden ? (
<Overridden>* {i18n._(t`Credentials`)}</Overridden>
) : (
i18n._(t`Credentials`)
)
}
value={
credentialsToShow.length > 0 && (
<ChipGroup numChips={5}>
{credentialsToShow.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
)
}
alwaysVisible={credentialOverridden}
/>
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5}>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Instance Groups`)}
value={
<ChipGroup numChips={5}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<Detail
fullWidth
label={
jobTagsOverridden ? (
<Overridden>* {i18n._(t`Job Tags`)}</Overridden>
) : (
i18n._(t`Job Tags`)
)
}
value={
jobTagsOverridden.length > 0 && (
<ChipGroup numChips={5}>
{jobTagsToShow.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
)
}
alwaysVisible={jobTagsOverridden}
/>
<Detail
fullWidth
label={
skipTagsOverridden ? (
<Overridden>* {i18n._(t`Skip Tags`)}</Overridden>
) : (
i18n._(t`Skip Tags`)
)
}
value={
skipTagsToShow.length > 0 && (
<ChipGroup numChips={5}>
{skipTagsToShow.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
)
}
alwaysVisible={skipTagsOverridden}
/>
<VariablesDetail
label={
variablesOverridden ? (
<Overridden>* {i18n._(t`Variables`)}</Overridden>
) : (
i18n._(t`Variables`)
)
}
value={variablesToShow}
rows={4}
/>
</DetailList>
{(jobTypeOverridden ||
limitOverridden ||
scmBranchOverridden ||
inventoryOverridden ||
verbosityOverridden ||
jobTagsOverridden ||
skipTagsOverridden ||
diffModeOverridden ||
credentialOverridden ||
variablesOverridden) && (
<>
<br />
<Overridden>
<b>
<Trans>
* Values for these fields differ from the job template's default
</Trans>
</b>
</Overridden>
</>
)}
</>
);
}
export default withI18n()(JobTemplateDetails);

View File

@ -1,38 +0,0 @@
import React from 'react';
import { Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import ApprovalDetails from './ApprovalDetails';
import InventorySourceSyncDetails from './InventorySourceSyncDetails';
import JobTemplateDetails from './JobTemplateDetails';
import ProjectSyncDetails from './ProjectSyncDetails';
import WorkflowJobTemplateDetails from './WorkflowJobTemplateDetails';
function NodeViewModal({ i18n, onClose, node }) {
return (
<Modal
isLarge
isOpen={true}
title={i18n._(t`Node Details | ${node.unifiedJobTemplate.name}`)}
onClose={onClose}
>
{(node.unifiedJobTemplate.type === 'job_template' || node.unifiedJobTemplate.unified_job_type === 'job') && (
<JobTemplateDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'workflow_approval_template' || node.unifiedJobTemplate.unified_job_type) === 'workflow_approval' && (
<ApprovalDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'project' || node.unifiedJobTemplate.unified_job_type === 'project_update') && (
<ProjectSyncDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'inventory_source' || node.unifiedJobTemplate.unified_job_type === 'inventory_update') && (
<InventorySourceSyncDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'workflow_job_template' || node.unifiedJobTemplate.unified_job_type === 'workflow_job') && (
<WorkflowJobTemplateDetails node={node} />
)}
</Modal>
);
}
export default withI18n()(NodeViewModal);

View File

@ -1,141 +0,0 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { ProjectsAPI } from '@api';
import { Config } from '@contexts/Config';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { CredentialChip } from '@components/Chip';
import { toTitleCase } from '@util/strings';
function ProjectSyncDetails({ i18n, node }) {
const [project, setProject] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
useEffect(() => {
async function fetchProject() {
try {
const { data } = await ProjectsAPI.readDetail(
node.unifiedJobTemplate.id
);
setProject(data);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchProject();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this project so the
displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Project Sync`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
custom_virtualenv,
description,
local_path,
name,
scm_branch,
scm_refspec,
scm_type,
scm_update_cache_timeout,
scm_url,
summary_fields,
} = project;
return (
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Project Sync`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={summary_fields.organization.name}
/>
)}
<Detail
label={i18n._(t`SCM Type`)}
value={
scm_type === '' ? i18n._(t`Manual`) : toTitleCase(project.scm_type)
}
/>
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
<Detail label={i18n._(t`SCM Refspec`)} value={scm_refspec} />
{summary_fields.credential && (
<Detail
label={i18n._(t`SCM Credential`)}
value={
<CredentialChip
key={summary_fields.credential.id}
credential={summary_fields.credential}
isReadOnly
/>
}
/>
)}
<Detail
label={i18n._(t`Cache Timeout`)}
value={`${scm_update_cache_timeout} ${i18n._(t`Seconds`)}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Config>
{({ project_base_dir }) => (
<Detail
label={i18n._(t`Project Base Path`)}
value={project_base_dir}
/>
)}
</Config>
<Detail label={i18n._(t`Playbook Directory`)} value={local_path} />
</DetailList>
);
}
export default withI18n()(ProjectSyncDetails);

View File

@ -1,129 +0,0 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { WorkflowJobTemplatesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip } from '@components/Chip';
import { VariablesDetail } from '@components/CodeMirrorInput';
function WorkflowJobTemplateDetails({ i18n, node }) {
const [workflowJobTemplate, setWorkflowJobTemplate] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
useEffect(() => {
async function fetchWorkflowJobTemplate() {
try {
const { data } = await WorkflowJobTemplatesAPI.readDetail(
node.unifiedJobTemplate.id
);
setWorkflowJobTemplate(data);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchWorkflowJobTemplate();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this workflow job template
so the displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Workflow Job Template`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
description,
extra_vars,
limit,
name,
scm_branch,
summary_fields,
} = workflowJobTemplate;
return (
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Workflow Job Template`)}
/>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={summary_fields.organization.name}
/>
)}
{summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={summary_fields.inventory.name}
/>
)}
<Detail label={i18n._(t`Limit`)} value={limit} />
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5}>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<VariablesDetail
label={i18n._(t`Variables`)}
value={extra_vars}
rows={4}
/>
</DetailList>
);
}
export default withI18n()(WorkflowJobTemplateDetails);

View File

@ -1,14 +1,14 @@
import React from 'react';
import { Button, Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import { func } from 'prop-types';
function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
return (
<Modal
width={600}
isOpen={true}
isOpen
title={i18n._(t`Warning: Unsaved Changes`)}
onClose={onCancel}
actions={[
@ -40,4 +40,10 @@ function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
);
}
UnsavedChangesModal.propTypes = {
onCancel: func.isRequired,
onExit: func.isRequired,
onSaveAndExit: func.isRequired,
};
export default withI18n()(UnsavedChangesModal);

View File

@ -0,0 +1,6 @@
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

@ -3,31 +3,34 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { shape } from 'prop-types';
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
import { layoutGraph } from '@util/workflow';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import DeleteAllNodesModal from './Modals/DeleteAllNodesModal';
import LinkModal from './Modals/LinkModal';
import LinkDeleteModal from './Modals/LinkDeleteModal';
import NodeModal from './Modals/NodeModal/NodeModal';
import NodeDeleteModal from './Modals/NodeDeleteModal';
import {
DeleteAllNodesModal,
LinkModal,
LinkDeleteModal,
NodeDeleteModal,
NodeViewModal,
UnsavedChangesModal,
} from './Modals';
import { NodeModal } from './Modals/NodeModal';
import VisualizerGraph from './VisualizerGraph';
import VisualizerStartScreen from './VisualizerStartScreen';
import VisualizerToolbar from './VisualizerToolbar';
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
import NodeViewModal from './Modals/NodeViewModal/NodeViewModal';
import {
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
} from '@api';
const CenteredContent = styled.div`
align-items: center;
display: flex;
flex-flow: column;
height: 100%;
align-items: center;
justify-content: center;
`;
@ -42,46 +45,42 @@ const fetchWorkflowNodes = async (
pageNo = 1,
workflowNodes = []
) => {
try {
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
page_size: 200,
page: pageNo,
});
if (data.next) {
return await fetchWorkflowNodes(
templateId,
pageNo + 1,
workflowNodes.concat(data.results)
);
}
return workflowNodes.concat(data.results);
} catch (error) {
throw error;
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
page_size: 200,
page: pageNo,
});
if (data.next) {
return fetchWorkflowNodes(
templateId,
pageNo + 1,
workflowNodes.concat(data.results)
);
}
return workflowNodes.concat(data.results);
};
function Visualizer({ history, template, i18n }) {
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [links, setLinks] = useState([]);
const [nodes, setNodes] = useState([]);
const [linkToDelete, setLinkToDelete] = useState(null);
const [linkToEdit, setLinkToEdit] = useState(null);
const [nodePositions, setNodePositions] = useState(null);
const [nodeToDelete, setNodeToDelete] = useState(null);
const [nodeToEdit, setNodeToEdit] = useState(null);
const [nodeToView, setNodeToView] = useState(null);
const [addingLink, setAddingLink] = useState(false);
const [addLinkSourceNode, setAddLinkSourceNode] = useState(null);
const [addLinkTargetNode, setAddLinkTargetNode] = useState(null);
const [addNodeSource, setAddNodeSource] = useState(null);
const [addNodeTarget, setAddNodeTarget] = useState(null);
const [addingLink, setAddingLink] = useState(false);
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [linkToDelete, setLinkToDelete] = useState(null);
const [linkToEdit, setLinkToEdit] = useState(null);
const [links, setLinks] = useState([]);
const [nextNodeId, setNextNodeId] = useState(0);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false);
const [nodePositions, setNodePositions] = useState(null);
const [nodeToDelete, setNodeToDelete] = useState(null);
const [nodeToEdit, setNodeToEdit] = useState(null);
const [nodeToView, setNodeToView] = useState(null);
const [nodes, setNodes] = useState([]);
const [showDeleteAllNodesModal, setShowDeleteAllNodesModal] = useState(false);
const [showKey, setShowKey] = useState(false);
const [showTools, setShowTools] = useState(false);
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const startAddNode = (sourceNodeId, targetNodeId = null) => {
setAddNodeSource(sourceNodeId);
@ -100,13 +99,13 @@ function Visualizer({ history, template, i18n }) {
// Ensures that root nodes appear to always run
// after "START"
if (addNodeSource === 1) {
newNode.edgeType = 'always';
newNode.linkType = 'always';
}
newLinks.push({
source: { id: addNodeSource },
target: { id: nextNodeId },
edgeType: newNode.edgeType,
linkType: newNode.linkType,
type: 'link',
});
if (addNodeTarget) {
@ -129,8 +128,8 @@ function Visualizer({ history, template, i18n }) {
setLinks(newLinks);
};
const startEditNode = nodeToEdit => {
setNodeToEdit(nodeToEdit);
const startEditNode = node => {
setNodeToEdit(node);
};
const finishEditingNode = editedNode => {
@ -175,7 +174,7 @@ function Visualizer({ history, template, i18n }) {
if (link.source.id === nodeId || link.target.id === nodeId) {
if (link.source.id === nodeId) {
children.push({ id: link.target.id, edgeType: link.edgeType });
children.push({ id: link.target.id, linkType: link.linkType });
} else if (link.target.id === nodeId) {
parents.push(link.source.id);
}
@ -193,7 +192,7 @@ function Visualizer({ history, template, i18n }) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
edgeType: 'always',
linkType: 'always',
type: 'link',
});
}
@ -201,7 +200,7 @@ function Visualizer({ history, template, i18n }) {
newLinks.push({
source: { id: parentId },
target: { id: child.id },
edgeType: child.edgeType,
linkType: child.linkType,
type: 'link',
});
}
@ -217,14 +216,14 @@ function Visualizer({ history, template, i18n }) {
setLinks(newLinks);
};
const updateLink = edgeType => {
const updateLink = linkType => {
const newLinks = [...links];
newLinks.forEach(link => {
if (
link.source.id === linkToEdit.source.id &&
link.target.id === linkToEdit.target.id
) {
link.edgeType = edgeType;
link.linkType = linkType;
}
});
@ -236,12 +235,12 @@ function Visualizer({ history, template, i18n }) {
};
const startDeleteLink = link => {
let parentMap = {};
links.forEach(link => {
if (!parentMap[link.target.id]) {
parentMap[link.target.id] = [];
const parentMap = {};
links.forEach(existingLink => {
if (!parentMap[existingLink.target.id]) {
parentMap[existingLink.target.id] = [];
}
parentMap[link.target.id].push(link.source.id);
parentMap[existingLink.target.id].push(existingLink.source.id);
});
link.isConvergenceLink = parentMap[link.target.id].length > 1;
@ -272,7 +271,7 @@ function Visualizer({ history, template, i18n }) {
target: {
id: linkToDelete.target.id,
},
edgeType: 'always',
linkType: 'always',
type: 'link',
});
}
@ -286,8 +285,8 @@ function Visualizer({ history, template, i18n }) {
const selectSourceNodeForLinking = sourceNode => {
const newNodes = [...nodes];
let parentMap = {};
let invalidLinkTargetIds = [];
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
@ -303,7 +302,7 @@ function Visualizer({ history, template, i18n }) {
}
});
let getAncestors = id => {
const getAncestors = id => {
if (parentMap[id]) {
parentMap[id].forEach(parentId => {
invalidLinkTargetIds.push(parentId);
@ -334,7 +333,7 @@ function Visualizer({ history, template, i18n }) {
setAddLinkTargetNode(targetNode);
};
const addLink = edgeType => {
const addLink = linkType => {
const newLinks = [...links];
const newNodes = [...nodes];
@ -345,7 +344,7 @@ function Visualizer({ history, template, i18n }) {
newLinks.push({
source: { id: addLinkSourceNode.id },
target: { id: addLinkTargetNode.id },
edgeType,
linkType,
type: 'link',
});
@ -513,7 +512,6 @@ function Visualizer({ history, template, i18n }) {
}
});
// TODO: error handling?
await Promise.all(nodeRequests);
await Promise.all(approvalTemplateRequests);
@ -529,8 +527,8 @@ function Visualizer({ history, template, i18n }) {
if (!linkMap[realLinkSourceId]) {
linkMap[realLinkSourceId] = {};
}
linkMap[realLinkSourceId][realLinkTargetId] = link.edgeType;
switch (link.edgeType) {
linkMap[realLinkSourceId][realLinkTargetId] = link.linkType;
switch (link.linkType) {
case 'success':
if (
!originalLinkMap[link.source.id].success_nodes.includes(
@ -563,7 +561,8 @@ function Visualizer({ history, template, i18n }) {
}
});
for (const [nodeId, node] of Object.entries(originalLinkMap)) {
Object.keys(originalLinkMap).forEach(key => {
const node = originalLinkMap[key];
node.success_nodes.forEach(successNodeId => {
if (
!deletedNodeIds.includes(successNodeId) &&
@ -609,13 +608,12 @@ function Visualizer({ history, template, i18n }) {
);
}
});
}
});
// TODO: error handling?
await Promise.all(disassociateRequests);
newLinks.forEach(link => {
switch (link.edgeType) {
switch (link.linkType) {
case 'success':
associateRequests.push(
WorkflowJobTemplateNodesAPI.associateSuccessNode(
@ -644,7 +642,6 @@ function Visualizer({ history, template, i18n }) {
}
});
// TODO: error handling?
await Promise.all(associateRequests);
// Some nodes (both new and edited) are going to need a followup request to
@ -655,11 +652,8 @@ function Visualizer({ history, template, i18n }) {
useEffect(() => {
const buildGraphArrays = workflowNodes => {
const nonRootNodeIds = [];
const allNodeIds = [];
const arrayOfLinksForChart = [];
const nodeIdToChartNodeIdMapping = {};
const chartNodeIdToIndexMapping = {};
const arrayOfNodesForChart = [
{
id: 1,
@ -669,6 +663,9 @@ function Visualizer({ history, template, i18n }) {
type: 'node',
},
];
const chartNodeIdToIndexMapping = {};
const nodeIdToChartNodeIdMapping = {};
const nonRootNodeIds = [];
let nodeIdCounter = 2;
// Assign each node an ID - 1 is reserved for the start node. We need to
// make sure that we have an ID on every node including new nodes so the
@ -704,7 +701,7 @@ function Visualizer({ history, template, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'success',
linkType: 'success',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -715,7 +712,7 @@ function Visualizer({ history, template, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'failure',
linkType: 'failure',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -726,7 +723,7 @@ function Visualizer({ history, template, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[sourceIndex],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'always',
linkType: 'always',
type: 'link',
});
nonRootNodeIds.push(nodeId);
@ -745,7 +742,7 @@ function Visualizer({ history, template, i18n }) {
arrayOfLinksForChart.push({
source: arrayOfNodesForChart[0],
target: arrayOfNodesForChart[targetIndex],
edgeType: 'always',
linkType: 'always',
type: 'link',
});
});
@ -803,33 +800,33 @@ function Visualizer({ history, template, i18n }) {
<Fragment>
<Wrapper>
<VisualizerToolbar
keyShown={showKey}
nodes={nodes}
template={template}
onClose={handleVisualizerClose}
onSave={handleVisualizerSave}
onDeleteAllClick={() => setShowDeleteAllNodesModal(true)}
onKeyToggle={() => setShowKey(!showKey)}
keyShown={showKey}
onSave={handleVisualizerSave}
onToolsToggle={() => setShowTools(!showTools)}
template={template}
toolsShown={showTools}
/>
{links.length > 0 ? (
<VisualizerGraph
links={links}
nodes={nodes}
nodePositions={nodePositions}
readOnly={!template.summary_fields.user_capabilities.edit}
onAddNodeClick={startAddNode}
onEditNodeClick={startEditNode}
onDeleteNodeClick={setNodeToDelete}
onLinkEditClick={setLinkToEdit}
onDeleteLinkClick={startDeleteLink}
onStartAddLinkClick={selectSourceNodeForLinking}
onConfirmAddLinkClick={selectTargetNodeForLinking}
onCancelAddLinkClick={cancelNodeLink}
onViewNodeClick={setNodeToView}
addingLink={addingLink}
addLinkSourceNode={addLinkSourceNode}
addingLink={addingLink}
links={links}
nodePositions={nodePositions}
nodes={nodes}
onAddNodeClick={startAddNode}
onCancelAddLinkClick={cancelNodeLink}
onConfirmAddLinkClick={selectTargetNodeForLinking}
onDeleteLinkClick={startDeleteLink}
onDeleteNodeClick={setNodeToDelete}
onEditNodeClick={startEditNode}
onLinkEditClick={setLinkToEdit}
onStartAddLinkClick={selectSourceNodeForLinking}
onViewNodeClick={setNodeToView}
readOnly={!template.summary_fields.user_capabilities.edit}
showKey={showKey}
showTools={showTools}
/>
@ -837,58 +834,58 @@ function Visualizer({ history, template, i18n }) {
<VisualizerStartScreen onStartClick={startAddNode} />
)}
</Wrapper>
<NodeDeleteModal
nodeToDelete={nodeToDelete}
onConfirm={deleteNode}
onCancel={() => setNodeToDelete(null)}
/>
{nodeToDelete && (
<NodeDeleteModal
nodeToDelete={nodeToDelete}
onCancel={() => setNodeToDelete(null)}
onConfirm={deleteNode}
/>
)}
{linkToDelete && (
<LinkDeleteModal
linkToDelete={linkToDelete}
onConfirm={deleteLink}
onCancel={() => setLinkToDelete(null)}
onConfirm={deleteLink}
/>
)}
{linkToEdit && (
<LinkModal
linkType={linkToEdit.linkType}
header={
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
{/* todo: make title match mockups (display: flex) */}
{i18n._(t`Edit Link`)}
</Title>
}
onConfirm={updateLink}
onCancel={() => setLinkToEdit(null)}
edgeType={linkToEdit.edgeType}
onConfirm={updateLink}
/>
)}
{addLinkSourceNode && addLinkTargetNode && (
<LinkModal
header={
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
{/* todo: make title match mockups (display: flex) */}
{i18n._(t`Add Link`)}
</Title>
}
onConfirm={addLink}
onCancel={cancelNodeLink}
onConfirm={addLink}
/>
)}
{addNodeSource && (
<NodeModal
askLinkType={addNodeSource !== 1}
title={i18n._(t`Add Node`)}
onClose={() => cancelNodeForm()}
onSave={finishAddingNode}
title={i18n._(t`Add Node`)}
/>
)}
{nodeToEdit && (
<NodeModal
askLinkType={false}
node={nodeToEdit}
title={i18n._(t`Edit Node`)}
nodeToEdit={nodeToEdit}
onClose={() => cancelNodeForm()}
onSave={finishEditingNode}
title={i18n._(t`Edit Node`)}
/>
)}
{showUnsavedChangesModal && (
@ -915,4 +912,8 @@ function Visualizer({ history, template, i18n }) {
);
}
Visualizer.propTypes = {
template: shape().isRequired,
};
export default withI18n()(withRouter(Visualizer));

View File

@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { arrayOf, bool, func, shape } from 'prop-types';
import * as d3 from 'd3';
import {
calcZoomAndFit,
@ -10,15 +11,15 @@ import {
} from '@util/workflow';
import {
WorkflowHelp,
WorkflowKey,
WorkflowLinkHelp,
WorkflowNodeHelp,
WorkflowTools,
} from '@components/Workflow';
import {
VisualizerLink,
VisualizerNode,
VisualizerStartNode,
VisualizerKey,
VisualizerTools,
} from '@screens/Template/WorkflowJobTemplateVisualizer';
const PotentialLink = styled.polyline`
@ -26,34 +27,34 @@ const PotentialLink = styled.polyline`
`;
const WorkflowSVG = styled.svg`
background-color: #f6f6f6;
display: flex;
height: 100%;
background-color: #f6f6f6;
`;
function VisualizerGraph({
addLinkSourceNode,
addingLink,
i18n,
links,
nodes,
readOnly,
nodePositions,
onDeleteNodeClick,
nodes,
onAddNodeClick,
onCancelAddLinkClick,
onConfirmAddLinkClick,
onDeleteLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onLinkEditClick,
onDeleteLinkClick,
onStartAddLinkClick,
onConfirmAddLinkClick,
onCancelAddLinkClick,
onViewNodeClick,
addingLink,
addLinkSourceNode,
readOnly,
showKey,
showTools,
i18n,
}) {
const [helpText, setHelpText] = useState(null);
const [nodeHelp, setNodeHelp] = useState();
const [linkHelp, setLinkHelp] = useState();
const [nodeHelp, setNodeHelp] = useState();
const [zoomPercentage, setZoomPercentage] = useState(100);
const svgRef = useRef(null);
const gRef = useRef(null);
@ -115,9 +116,10 @@ function VisualizerGraph({
};
const handlePan = direction => {
let { x: xPos, y: yPos, k: currentScale } = d3.zoomTransform(
d3.select(svgRef.current).node()
);
const transform = d3.zoomTransform(d3.select(svgRef.current).node());
let { x: xPos, y: yPos } = transform;
const { k: currentScale } = transform;
switch (direction) {
case 'up':
@ -223,23 +225,23 @@ function VisualizerGraph({
<WorkflowSVG id="workflow-svg" ref={svgRef} css="">
<defs>
<marker
id="workflow-triangle"
className="WorkflowChart-noPointerEvents"
viewBox="0 -5 10 10"
refX="10"
id="workflow-triangle"
markerHeight="6"
markerUnits="strokeWidth"
markerWidth="6"
markerHeight="6"
orient="auto"
refX="10"
viewBox="0 -5 10 10"
>
<path d="M0,-5L10,0L0,5" fill="#93969A" />
</marker>
</defs>
<rect
width="100%"
height="100%"
opacity="0"
id="workflow-backround"
opacity="0"
width="100%"
{...(addingLink && {
onMouseMove: e => drawPotentialLinkToCursor(e),
onMouseOver: () =>
@ -255,12 +257,12 @@ function VisualizerGraph({
<g id="workflow-g" ref={gRef}>
{nodePositions && [
<VisualizerStartNode
addingLink={addingLink}
key="start"
nodePositions={nodePositions}
onAddNodeClick={onAddNodeClick}
readOnly={readOnly}
updateHelpText={setHelpText}
addingLink={addingLink}
onAddNodeClick={onAddNodeClick}
/>,
links.map(link => {
if (
@ -269,16 +271,16 @@ 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}
updateHelpText={setHelpText}
updateLinkHelp={setLinkHelp}
readOnly={readOnly}
onLinkEditClick={onLinkEditClick}
onDeleteLinkClick={onDeleteLinkClick}
addingLink={addingLink}
onAddNodeClick={onAddNodeClick}
/>
);
}
@ -288,22 +290,22 @@ function VisualizerGraph({
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
return (
<VisualizerNode
key={`node-${node.id}`}
node={node}
nodePositions={nodePositions}
updateHelpText={setHelpText}
updateNodeHelp={setNodeHelp}
readOnly={readOnly}
onAddNodeClick={onAddNodeClick}
onEditNodeClick={onEditNodeClick}
onDeleteNodeClick={onDeleteNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
onViewNodeClick={onViewNodeClick}
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}
updateHelpText={setHelpText}
updateNodeHelp={setNodeHelp}
{...(addingLink && {
onMouseOver: () => drawPotentialLinkToNode(node),
})}
@ -316,28 +318,52 @@ function VisualizerGraph({
{addingLink && (
<PotentialLink
id="workflow-potentialLink"
markerEnd="url(#workflow-triangle)"
stroke="#93969A"
strokeDasharray="5,5"
strokeWidth="2"
stroke="#93969A"
markerEnd="url(#workflow-triangle)"
/>
)}
</g>
</WorkflowSVG>
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && (
<VisualizerTools
zoomPercentage={zoomPercentage}
onZoomChange={handleZoomChange}
<WorkflowTools
onFitGraph={handleFitGraph}
onPan={handlePan}
onPanToMiddle={handlePanToMiddle}
onZoomChange={handleZoomChange}
zoomPercentage={zoomPercentage}
/>
)}
{showKey && <VisualizerKey />}
{showKey && <WorkflowKey />}
</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,
onViewNodeClick: func.isRequired,
readOnly: bool.isRequired,
showKey: bool.isRequired,
showTools: bool.isRequired,
};
VisualizerGraph.defaultProps = {
addLinkSourceNode: {},
};
export default withI18n()(VisualizerGraph);

View File

@ -2,11 +2,12 @@ import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { bool, func, shape } from 'prop-types';
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
import {
generateLine,
getLinkOverlayPoints,
getLinePoints,
getLinkOverlayPoints,
} from '@util/workflow';
import {
WorkflowActionTooltip,
@ -18,16 +19,16 @@ const LinkG = styled.g`
`;
function VisualizerLink({
addingLink,
i18n,
link,
nodePositions,
onAddNodeClick,
onDeleteLinkClick,
onLinkEditClick,
readOnly,
updateHelpText,
updateLinkHelp,
i18n,
onLinkEditClick,
onDeleteLinkClick,
addingLink,
onAddNodeClick,
}) {
const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState();
@ -61,18 +62,18 @@ function VisualizerLink({
<WorkflowActionTooltipItem
id="link-edit"
key="edit"
onClick={() => onLinkEditClick(link)}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => onLinkEditClick(link)}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
<WorkflowActionTooltipItem
id="link-delete"
key="delete"
onClick={() => onDeleteLinkClick(link)}
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => onDeleteLinkClick(link)}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -95,16 +96,16 @@ function VisualizerLink({
};
useEffect(() => {
if (link.edgeType === 'failure') {
if (link.linkType === 'failure') {
setPathStroke('#d9534f');
}
if (link.edgeType === 'success') {
if (link.linkType === 'success') {
setPathStroke('#5cb85c');
}
if (link.edgeType === 'always') {
if (link.linkType === 'always') {
setPathStroke('#337ab7');
}
}, [link.edgeType]);
}, [link.linkType]);
useEffect(() => {
const linePoints = getLinePoints(link, nodePositions);
@ -117,13 +118,13 @@ function VisualizerLink({
<LinkG
className="WorkflowGraph-link"
id={`link-${link.source.id}-${link.target.id}`}
ignorePointerEvents={addingLink}
onMouseEnter={handleLinkMouseEnter}
onMouseLeave={handleLinkMouseLeave}
ignorePointerEvents={addingLink}
>
<polygon
id={`link-${link.source.id}-${link.target.id}-overlay`}
fill="#E1E1E1"
id={`link-${link.source.id}-${link.target.id}-overlay`}
opacity={hovering ? '1' : '0'}
points={getLinkOverlayPoints(link, nodePositions)}
/>
@ -134,20 +135,32 @@ function VisualizerLink({
strokeWidth="2px"
/>
<polygon
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
onMouseEnter={() => updateLinkHelp(link)}
onMouseLeave={() => updateLinkHelp(null)}
opacity="0"
points={getLinkOverlayPoints(link, nodePositions)}
/>
{!readOnly && hovering && (
<WorkflowActionTooltip
actions={tooltipActions}
pointX={tooltipX}
pointY={tooltipY}
actions={tooltipActions}
/>
)}
</LinkG>
);
}
VisualizerLink.propTypes = {
addingLink: bool.isRequired,
link: shape().isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
onDeleteLinkClick: func.isRequired,
onLinkEditClick: func.isRequired,
readOnly: bool.isRequired,
updateHelpText: func.isRequired,
updateLinkHelp: func.isRequired,
};
export default withI18n()(VisualizerLink);

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { bool, func, shape } from 'prop-types';
import {
InfoIcon,
LinkIcon,
@ -30,28 +31,28 @@ const NodeContents = styled.foreignObject`
const NodeDefaultLabel = styled.p`
margin-top: 20px;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
`;
function VisualizerNode({
addingLink,
i18n,
isAddLinkSourceNode,
node,
nodePositions,
onAddNodeClick,
onConfirmAddLinkClick,
onDeleteNodeClick,
onEditNodeClick,
onMouseOver,
onStartAddLinkClick,
onViewNodeClick,
readOnly,
updateHelpText,
updateNodeHelp,
readOnly,
i18n,
onDeleteNodeClick,
onStartAddLinkClick,
onConfirmAddLinkClick,
addingLink,
onMouseOver,
isAddLinkSourceNode,
onAddNodeClick,
onEditNodeClick,
onViewNodeClick,
}) {
const [hovering, setHovering] = useState(false);
@ -88,13 +89,13 @@ function VisualizerNode({
<WorkflowActionTooltipItem
id="node-details"
key="details"
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onViewNodeClick(node);
}}
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
>
<InfoIcon />
</WorkflowActionTooltipItem>
@ -106,13 +107,13 @@ function VisualizerNode({
<WorkflowActionTooltipItem
id="node-add"
key="add"
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onAddNodeClick(node.id);
}}
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => updateHelpText(null)}
>
<PlusIcon />
</WorkflowActionTooltipItem>,
@ -120,41 +121,41 @@ function VisualizerNode({
<WorkflowActionTooltipItem
id="node-edit"
key="edit"
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onEditNodeClick(node);
}}
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
<WorkflowActionTooltipItem
id="node-link"
key="link"
onMouseEnter={() =>
updateHelpText(i18n._(t`Link to an available node`))
}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onStartAddLinkClick(node);
}}
onMouseEnter={() =>
updateHelpText(i18n._(t`Link to an available node`))
}
onMouseLeave={() => updateHelpText(null)}
>
<LinkIcon />
</WorkflowActionTooltipItem>,
<WorkflowActionTooltipItem
id="node-delete"
key="delete"
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onDeleteNodeClick(node);
}}
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => updateHelpText(null)}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -163,15 +164,15 @@ function VisualizerNode({
return (
<NodeG
id={`node-${node.id}`}
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
.y - nodePositions[1].y})`}
job={node.job}
noPointerEvents={isAddLinkSourceNode}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={handleNodeMouseLeave}
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
.y - nodePositions[1].y})`}
>
<rect
width={wfConstants.nodeW}
fill="#FFFFFF"
height={wfConstants.nodeH}
rx="2"
ry="2"
@ -181,17 +182,17 @@ function VisualizerNode({
: '#93969A'
}
strokeWidth="4px"
fill="#FFFFFF"
width={wfConstants.nodeW}
/>
<NodeContents
height="60"
width="180"
isInvalidLinkTarget={node.isInvalidLinkTarget}
{...(!addingLink && {
onMouseEnter: () => updateNodeHelp(node),
onMouseLeave: () => updateNodeHelp(null),
})}
onClick={() => handleNodeClick()}
width="180"
>
<NodeDefaultLabel>
{node.unifiedJobTemplate
@ -211,4 +212,26 @@ 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,
updateHelpText: func.isRequired,
updateNodeHelp: func.isRequired,
};
VisualizerNode.defaultProps = {
isAddLinkSourceNode: false,
onMouseOver: () => {},
};
export default withI18n()(VisualizerNode);

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { bool, func, shape } from 'prop-types';
import { PlusIcon } from '@patternfly/react-icons';
import { constants as wfConstants } from '@util/workflow';
import {
@ -14,12 +15,12 @@ const StartG = styled.g`
`;
function VisualizerStartNode({
updateHelpText,
nodePositions,
readOnly,
i18n,
addingLink,
i18n,
nodePositions,
onAddNodeClick,
readOnly,
updateHelpText,
}) {
const [hovering, setHovering] = useState(false);
@ -32,18 +33,18 @@ function VisualizerStartNode({
return (
<StartG
id="node-1"
transform={`translate(${nodePositions[1].x},0)`}
ignorePointerEvents={addingLink}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={() => setHovering(false)}
ignorePointerEvents={addingLink}
transform={`translate(${nodePositions[1].x},0)`}
>
<rect
width={wfConstants.rootW}
fill="#0279BC"
height={wfConstants.rootH}
y="10"
rx="2"
ry="2"
fill="#0279BC"
width={wfConstants.rootW}
y="10"
/>
{/* TODO: We need to be able to handle translated text here */}
<text x="13" y="30" dy=".35em" fill="white">
@ -51,8 +52,6 @@ function VisualizerStartNode({
</text>
{!readOnly && hovering && (
<WorkflowActionTooltip
pointX={wfConstants.rootW}
pointY={wfConstants.rootH / 2 + 10}
actions={[
<WorkflowActionTooltipItem
id="node-add"
@ -68,10 +67,20 @@ function VisualizerStartNode({
<PlusIcon />
</WorkflowActionTooltipItem>,
]}
pointX={wfConstants.rootW}
pointY={wfConstants.rootH / 2 + 10}
/>
)}
</StartG>
);
}
VisualizerStartNode.propTypes = {
addingLink: bool.isRequired,
nodePositions: shape().isRequired,
onAddNodeClick: func.isRequired,
readOnly: bool.isRequired,
updateHelpText: func.isRequired,
};
export default withI18n()(VisualizerStartNode);

View File

@ -1,6 +1,7 @@
import React from 'react';
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';
@ -14,30 +15,30 @@ const Button = styled(PFButton)`
`;
const StartPanel = styled.div`
padding: 60px 80px;
border: 1px solid #c7c7c7;
background-color: white;
border: 1px solid #c7c7c7;
padding: 60px 80px;
text-align: center;
`;
const StartPanelWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background-color: #f6f6f6;
display: flex;
height: 100%;
justify-content: center;
`;
function StartScreen({ i18n, onStartClick }) {
function VisualizerStartScreen({ i18n, onStartClick }) {
return (
<div css="flex: 1">
<StartPanelWrapper>
<StartPanel>
<p>{i18n._(t`Please click the Start button to begin.`)}</p>
<Button
variant="primary"
aria-label={i18n._(t`Start`)}
onClick={() => onStartClick(1)}
variant="primary"
>
{i18n._(t`Start`)}
</Button>
@ -47,4 +48,8 @@ function StartScreen({ i18n, onStartClick }) {
);
}
export default withI18n()(StartScreen);
VisualizerStartScreen.propTypes = {
onStartClick: func.isRequired,
};
export default withI18n()(VisualizerStartScreen);

View File

@ -1,11 +1,11 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { arrayOf, bool, func, shape } from 'prop-types';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import {
BookIcon,
CompassIcon,
DownloadIcon,
RocketIcon,
TimesIcon,
TrashAltIcon,
@ -36,68 +36,50 @@ const ActionButton = styled(Button)`
}
`;
function Toolbar({
function VisualizerToolbar({
i18n,
template,
keyShown,
nodes,
onClose,
onSave,
nodes = [],
onDeleteAllClick,
onKeyToggle,
keyShown,
onSave,
onToolsToggle,
template,
toolsShown,
}) {
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return (
<div>
<div
style={{
borderBottom: '1px solid grey',
height: '56px',
display: 'flex',
alignItems: 'center',
padding: '0px 20px',
}}
>
<div style={{ display: 'flex' }}>
<div css="align-items: center; border-bottom: 1px solid grey; display: flex; height: 56px; padding: 0px 20px;">
<div css="display: flex;">
<b>{i18n._(t`Workflow Visualizer`)}</b>
<VerticalSeparator />
<b>{template.name}</b>
</div>
<div
style={{
display: 'flex',
flex: '1',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<div css="align-items: center; display: flex; flex: 1; justify-content: flex-end">
<div>{i18n._(t`Total Nodes`)}</div>
<Badge isRead>{totalNodes}</Badge>
<VerticalSeparator />
<Tooltip content={i18n._(t`Toggle Key`)} position="bottom">
<ActionButton
variant="plain"
onClick={onKeyToggle}
isActive={keyShown}
onClick={onKeyToggle}
variant="plain"
>
<CompassIcon />
</ActionButton>
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
variant="plain"
onClick={onToolsToggle}
isActive={toolsShown}
onClick={onToolsToggle}
variant="plain"
>
<WrenchIcon />
</ActionButton>
</Tooltip>
<ActionButton variant="plain" isDisabled>
<DownloadIcon />
</ActionButton>
<ActionButton variant="plain" isDisabled>
<BookIcon />
</ActionButton>
@ -106,10 +88,10 @@ function Toolbar({
</ActionButton>
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom">
<ActionButton
variant="plain"
isDisabled={totalNodes === 0}
aria-label={i18n._(t`Delete all nodes`)}
isDisabled={totalNodes === 0}
onClick={onDeleteAllClick}
variant="plain"
>
<TrashAltIcon />
</ActionButton>
@ -120,9 +102,9 @@ function Toolbar({
</Button>
<VerticalSeparator />
<Button
variant="plain"
aria-label={i18n._(t`Close`)}
onClick={onClose}
variant="plain"
>
<TimesIcon />
</Button>
@ -132,4 +114,20 @@ function Toolbar({
);
}
export default withI18n()(Toolbar);
VisualizerToolbar.propTypes = {
keyShown: bool.isRequired,
nodes: arrayOf(shape()),
onClose: func.isRequired,
onDeleteAllClick: func.isRequired,
onKeyToggle: func.isRequired,
onSave: func.isRequired,
onToolsToggle: func.isRequired,
template: shape().isRequired,
toolsShown: bool.isRequired,
};
VisualizerToolbar.defaultProps = {
nodes: [],
};
export default withI18n()(VisualizerToolbar);

View File

@ -1,9 +1,7 @@
export { default as Visualizer } from './Visualizer';
export { default as VisualizerToolbar } from './VisualizerToolbar';
export { default as VisualizerGraph } from './VisualizerGraph';
export { default as VisualizerStartScreen } from './VisualizerStartScreen';
export { default as VisualizerStartNode } from './VisualizerStartNode';
export { default as VisualizerLink } from './VisualizerLink';
export { default as VisualizerNode } from './VisualizerNode';
export { default as VisualizerKey } from './VisualizerKey';
export { default as VisualizerTools } from './VisualizerTools';
export { default as VisualizerStartNode } from './VisualizerStartNode';
export { default as VisualizerStartScreen } from './VisualizerStartScreen';
export { default as VisualizerToolbar } from './VisualizerToolbar';

View File

@ -18,8 +18,8 @@ export function calcZoomAndFit(gRef, svgRef) {
.node()
.getBoundingClientRect();
gBoundingClientRect.height = gBoundingClientRect.height / currentScale;
gBoundingClientRect.width = gBoundingClientRect.width / currentScale;
gBoundingClientRect.height /= currentScale;
gBoundingClientRect.width /= currentScale;
const gBBoxDimensions = d3
.select(gRef)