Fully functioning workflow editor without read-only view modal and without prompting.

This commit is contained in:
mabashian 2020-01-13 11:13:40 -05:00
parent ca478ac880
commit de55af6ae6
40 changed files with 2799 additions and 169 deletions

View File

@ -22,7 +22,9 @@ import Teams from './models/Teams';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobs from './models/WorkflowJobs';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
const AdHocCommandsAPI = new AdHocCommands();
@ -49,7 +51,9 @@ const TeamsAPI = new Teams();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
export {
@ -77,6 +81,8 @@ export {
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobsAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
};

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class WorkflowApprovalTemplates extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/workflow_approval_templates/';
}
}
export default WorkflowApprovalTemplates;

View File

@ -0,0 +1,56 @@
import Base from '../Base';
class WorkflowJobTemplateNodes extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/workflow_job_template_nodes/';
}
createApprovalTemplate(id, data) {
return this.http.post(
`${this.baseUrl}${id}/create_approval_template/`,
data
);
}
associateSuccessNode(id, idToAssociate) {
return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
id: idToAssociate,
});
}
associateFailureNode(id, idToAssociate) {
return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
id: idToAssociate,
});
}
associateAlwaysNode(id, idToAssociate) {
return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
id: idToAssociate,
});
}
disassociateSuccessNode(id, idToDissociate) {
return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
id: idToDissociate,
disassociate: true,
});
}
disassociateFailuresNode(id, idToDissociate) {
return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
id: idToDissociate,
disassociate: true,
});
}
disassociateAlwaysNode(id, idToDissociate) {
return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
id: idToDissociate,
disassociate: true,
});
}
}
export default WorkflowJobTemplateNodes;

View File

@ -9,6 +9,10 @@ class WorkflowJobTemplates extends Base {
readNodes(id, params) {
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
}
createNode(id, data) {
return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
}
}
export default WorkflowJobTemplates;

View File

@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import SelectableCard from './SelectableCard';
import { SelectableCard } from '@components/SelectableCard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>

View File

@ -1,5 +1,4 @@
export { default as AddResourceRole } from './AddResourceRole';
export { default as CheckboxCard } from './CheckboxCard';
export { default as SelectableCard } from './SelectableCard';
export { default as SelectResourceStep } from './SelectResourceStep';
export { default as SelectRoleStep } from './SelectRoleStep';

View File

@ -0,0 +1,14 @@
import React from 'react';
import styled from 'styled-components';
const Separator = styled.div`
width: 100%;
height: 1px;
margin-top: 20px;
margin-bottom: 20px;
background-color: #d7d7d7;
`;
const HorizontalSeparator = () => <Separator />;
export default HorizontalSeparator;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { mount } from 'enzyme';
import HorizontalSeparator from './HorizontalSeparator';
describe('HorizontalSeparator', () => {
test('renders the expected content', () => {
const wrapper = mount(<HorizontalSeparator />);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './HorizontalSeparator';

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
@ -12,7 +12,6 @@ const SelectableItem = styled.div`
? 'var(--pf-global--active-color--100)'
: 'var(--pf-global--BorderColor--200)'};
margin-right: 20px;
font-weight: bold;
display: flex;
cursor: pointer;
`;
@ -24,31 +23,31 @@ const Indicator = styled.div`
props.isSelected ? 'var(--pf-global--active-color--100)' : null};
`;
const Label = styled.div`
display: flex;
flex: 1;
align-items: center;
padding: 20px;
const Contents = styled.div`
padding: 10px 20px;
`;
class SelectableCard extends Component {
render() {
const { label, onClick, isSelected, dataCy } = this.props;
const Description = styled.p`
font-size: 14px;
`;
return (
<SelectableItem
onClick={onClick}
onKeyPress={onClick}
role="button"
tabIndex="0"
data-cy={dataCy}
isSelected={isSelected}
>
<Indicator isSelected={isSelected} />
<Label>{label}</Label>
</SelectableItem>
);
}
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
return (
<SelectableItem
onClick={onClick}
onKeyPress={onClick}
role="button"
tabIndex="0"
data-cy={dataCy}
isSelected={isSelected}
>
<Indicator isSelected={isSelected} />
<Contents>
<b>{label}</b>
<Description>{description}</Description>
</Contents>
</SelectableItem>
);
}
SelectableCard.propTypes = {
@ -59,6 +58,7 @@ SelectableCard.propTypes = {
SelectableCard.defaultProps = {
label: '',
description: '',
isSelected: false,
};

View File

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

View File

@ -20,19 +20,26 @@ const GridDL = styled.dl`
function WorkflowNodeHelp({ node, i18n }) {
let nodeType;
if (node.unifiedJobTemplate) {
switch (node.unifiedJobTemplate.unified_job_type) {
const type =
node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type;
switch (type) {
case 'job_template':
case 'job':
nodeType = i18n._(t`Job Template`);
break;
case 'workflow_job_template':
case 'workflow_job':
nodeType = i18n._(t`Workflow Job Template`);
break;
case 'project':
case 'project_update':
nodeType = i18n._(t`Project Update`);
break;
case 'inventory_source':
case 'inventory_update':
nodeType = i18n._(t`Inventory Update`);
break;
case 'workflow_approval_template':
case 'workflow_approval':
nodeType = i18n._(t`Workflow Approval`);
break;

View File

@ -13,43 +13,30 @@ const NodeTypeLetter = styled.foreignObject`
function WorkflowNodeTypeLetter({ node }) {
let nodeTypeLetter;
if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) {
switch (node.unifiedJobTemplate.type) {
case 'job_template':
nodeTypeLetter = 'JT';
break;
case 'project':
nodeTypeLetter = 'P';
break;
case 'inventory_source':
nodeTypeLetter = 'I';
break;
case 'workflow_job_template':
nodeTypeLetter = 'W';
break;
case 'workflow_approval_template':
nodeTypeLetter = <PauseIcon />;
break;
default:
nodeTypeLetter = '';
}
} else if (
if (
node.unifiedJobTemplate &&
node.unifiedJobTemplate.unified_job_type
(node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type)
) {
switch (node.unifiedJobTemplate.unified_job_type) {
const ujtType =
node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
nodeTypeLetter = 'JT';
break;
case 'project':
case 'project_update':
nodeTypeLetter = 'P';
break;
case 'inventory_source':
case 'inventory_update':
nodeTypeLetter = 'I';
break;
case 'workflow_job_template':
case 'workflow_job':
nodeTypeLetter = 'W';
break;
case 'workflow_approval_template':
case 'workflow_approval':
nodeTypeLetter = <PauseIcon />;
break;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
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"
variant="danger"
aria-label={i18n._(t`Confirm removal of all nodes`)}
onClick={() => onConfirm()}
>
{i18n._(t`Remove`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel node removal`)}
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<p>
{i18n._(
t`Are you sure you want to remove all the nodes in this workflow?`
)}
</p>
</AlertModal>
);
}
export default withI18n()(DeleteAllNodesModal);

View File

@ -0,0 +1,48 @@
import React, { Fragment } from 'react';
import { Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '@components/AlertModal';
function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) {
return (
<AlertModal
variant="danger"
title="Remove Link"
isOpen={linkToDelete}
onClose={onCancel}
actions={[
<Button
key="remove"
variant="danger"
aria-label={i18n._(t`Confirm link removal`)}
onClick={() => onConfirm()}
>
{i18n._(t`Remove`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel link removal`)}
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<p>{i18n._(t`Are you sure you want to remove this link?`)}</p>
{!linkToDelete.isConvergenceLink && (
<Fragment>
<br />
<p>
{i18n._(
t`Removing this link will orphan the rest of the branch and cause it to be executed immediately on launch.`
)}
</p>
</Fragment>
)}
</AlertModal>
);
}
export default withI18n()(LinkDeleteModal);

View File

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { Button, Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import AnsibleSelect from '@components/AnsibleSelect';
function LinkModal({
i18n,
header,
onCancel,
onConfirm,
edgeType = 'success',
}) {
const [newEdgeType, setNewEdgeType] = useState(edgeType);
return (
<Modal
width={600}
header={header}
isOpen={true}
title={i18n._(t`Workflow Link`)}
onClose={onCancel}
actions={[
<Button
key="save"
variant="primary"
aria-label={i18n._(t`Save link changes`)}
onClick={() => onConfirm(newEdgeType)}
>
{i18n._(t`Save`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Cancel link changes`)}
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<FormGroup fieldId="link-select" label={i18n._(t`Run`)}>
<AnsibleSelect
id="link-select"
value={newEdgeType}
data={[
{
value: 'always',
key: 'always',
label: i18n._(t`Always`),
},
{
value: 'success',
key: 'success',
label: i18n._(t`On Success`),
},
{
value: 'failure',
key: 'failure',
label: i18n._(t`On Failure`),
},
]}
onChange={(event, value) => {
setNewEdgeType(value);
}}
/>
</FormGroup>
</Modal>
);
}
export default withI18n()(LinkModal);

View File

@ -8,7 +8,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
return (
<AlertModal
variant="danger"
title="Remove Node"
title={i18n._(t`Remove Node`)}
isOpen={nodeToDelete}
onClose={onCancel}
actions={[

View File

@ -0,0 +1,49 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function ApprovalPreviewStep({ i18n, name, description, timeout, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'on_success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'on_failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
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 (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Approval Node`)}
</Title>
<HorizontalSeparator />
<DetailList>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Timeout`)} value={timeoutValue} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(ApprovalPreviewStep);

View File

@ -0,0 +1,39 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function InventorySyncPreviewStep({ i18n, inventorySource, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Inventory Sync Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={inventorySource.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(InventorySyncPreviewStep);

View File

@ -0,0 +1,185 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function JobTemplatePreviewStep({ i18n, jobTemplate, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Job Template Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={jobTemplate.name} />
<Detail
label={i18n._(t`Description`)}
value={jobTemplate.description}
/>
{/* <Detail label={i18n._(t`Job Type`)} value={job_type} />
{summary_fields.inventory ? (
<Detail
label={i18n._(t`Inventory`)}
value={inventoryValue(
summary_fields.inventory.kind,
summary_fields.inventory.id
)}
/>
) : (
!ask_inventory_on_launch &&
renderMissingDataDetail(i18n._(t`Inventory`))
)}
{summary_fields.project ? (
<Detail
label={i18n._(t`Project`)}
value={
<Link to={`/projects/${summary_fields.project.id}/details`}>
{summary_fields.project
? summary_fields.project.name
: i18n._(t`Deleted`)}
</Link>
}
/>
) : (
renderMissingDataDetail(i18n._(t`Project`))
)}
<Detail label={i18n._(t`Playbook`)} value={playbook} />
<Detail label={i18n._(t`Forks`)} value={forks || '0'} />
<Detail label={i18n._(t`Limit`)} value={limit} />
<Detail
label={i18n._(t`Verbosity`)}
value={verbosityDetails[0].details}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
{createdBy && (
<Detail
label={i18n._(t`Created`)}
value={createdBy} // TODO: link to user in users
/>
)}
{modifiedBy && (
<Detail
label={i18n._(t`Last Modified`)}
value={modifiedBy} // TODO: link to user in users
/>
)}
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
/>
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
{host_config_key && (
<React.Fragment>
<Detail
label={i18n._(t`Host Config Key`)}
value={host_config_key}
/>
<Detail
label={i18n._(t`Provisioning Callback URL`)}
value={generateCallBackUrl}
/>
</React.Fragment>
)}
{renderOptionsField && (
<Detail label={i18n._(t`Options`)} value={renderOptions} />
)}
{summary_fields.credentials &&
summary_fields.credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5}>
{summary_fields.credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{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>
}
/>
)}
{job_tags && job_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Job tags`)}
value={
<ChipGroup numChips={5}>
{job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{skip_tags && skip_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Skip tags`)}
value={
<ChipGroup numChips={5}>
{skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
)} */}
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(JobTemplatePreviewStep);

View File

@ -0,0 +1,161 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Formik, Field } from 'formik';
import { Form, FormGroup, TextInput, Title } from '@patternfly/react-core';
import FormRow from '@components/FormRow';
import HorizontalSeparator from '@components/HorizontalSeparator';
const TimeoutInput = styled(TextInput)`
width: 200px;
:not(:first-of-type) {
margin-left: 20px;
}
`;
const TimeoutLabel = styled.p`
margin-left: 10px;
`;
function NodeApprovalStep({
i18n,
name,
updateName,
description,
updateDescription,
timeout = 0,
updateTimeout,
}) {
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Approval Node`)}
</Title>
<HorizontalSeparator />
<Formik
initialValues={{
name: name || '',
description: description || '',
timeoutMinutes: Math.floor(timeout / 60),
timeoutSeconds: timeout - Math.floor(timeout / 60) * 60,
}}
render={() => (
<Form>
<FormRow>
<Field
name="name"
render={({ field, form }) => {
const isValid =
form &&
(!form.touched[field.name] || !form.errors[field.name]);
return (
<FormGroup
fieldId="approval-name"
isRequired={true}
isValid={isValid}
label={i18n._(t`Name`)}
>
<TextInput
id="approval-name"
isRequired={true}
isValid={isValid}
type="text"
{...field}
onChange={(value, event) => {
updateName(value);
field.onChange(event);
}}
autoFocus
/>
</FormGroup>
);
}}
/>
</FormRow>
<FormRow>
<Field
name="description"
render={({ field }) => (
<FormGroup
fieldId="approval-description"
label={i18n._(t`Description`)}
>
<TextInput
id="approval-description"
type="text"
{...field}
onChange={value => {
updateDescription(value);
field.onChange(event);
}}
/>
</FormGroup>
)}
/>
</FormRow>
<FormRow>
<FormGroup label={i18n._(t`Timeout`)} fieldId="approval-timeout">
<div css="display: flex;align-items: center;">
<Field
name="timeoutMinutes"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-minutes"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) * 60 +
Number(form.values.timeoutSeconds)
);
field.onChange(event);
}}
/>
<TimeoutLabel>min</TimeoutLabel>
</>
)}
/>
<Field
name="timeoutSeconds"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-seconds"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) +
Number(form.values.timeoutMinutes) * 60
);
field.onChange(event);
}}
/>
<TimeoutLabel>sec</TimeoutLabel>
</>
)}
/>
</div>
</FormGroup>
</FormRow>
</Form>
)}
/>
</div>
);
}
export default withI18n()(NodeApprovalStep);

View File

@ -0,0 +1,310 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Wizard,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import NodeResourceStep from './NodeResourceStep';
import NodeTypeStep from './NodeTypeStep';
import NodeNextButton from './NodeNextButton';
import NodeApprovalStep from './NodeApprovalStep';
import ApprovalPreviewStep from './ApprovalPreviewStep';
import JobTemplatePreviewStep from './JobTemplatePreviewStep';
import InventorySyncPreviewStep from './InventorySyncPreviewStep';
import ProjectSyncPreviewStep from './ProjectSyncPreviewStep';
import WorkflowJobTemplatePreviewStep from './WorkflowJobTemplatePreviewStep';
import {
JobTemplatesAPI,
ProjectsAPI,
InventorySourcesAPI,
WorkflowJobTemplatesAPI,
} from '@api';
const readInventorySources = async queryParams =>
InventorySourcesAPI.read(queryParams);
const readJobTemplates = async queryParams =>
JobTemplatesAPI.read(queryParams, { role_level: 'execute_role' });
const readProjects = async queryParams => ProjectsAPI.read(queryParams);
const readWorkflowJobTemplates = async queryParams =>
WorkflowJobTemplatesAPI.read(queryParams, { role_level: 'execute_role' });
function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
let defaultNodeType = 'job_template';
let defaultNodeResource = null;
let defaultApprovalName = '';
let defaultApprovalDescription = '';
let defaultApprovalTimeout = 0;
if (node && node.unifiedJobTemplate) {
if (
node &&
node.unifiedJobTemplate &&
(node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type)
) {
const ujtType =
node.unifiedJobTemplate.type ||
node.unifiedJobTemplate.unified_job_type;
switch (ujtType) {
case 'job_template':
case 'job':
defaultNodeType = 'job_template';
defaultNodeResource = node.unifiedJobTemplate;
break;
case 'project':
case 'project_update':
defaultNodeType = 'project_sync';
defaultNodeResource = node.unifiedJobTemplate;
break;
case 'inventory_source':
case 'inventory_update':
defaultNodeType = 'inventory_source_sync';
defaultNodeResource = node.unifiedJobTemplate;
break;
case 'workflow_job_template':
case 'workflow_job':
defaultNodeType = 'workflow_job_template';
defaultNodeResource = node.unifiedJobTemplate;
break;
case 'workflow_approval_template':
case 'workflow_approval':
defaultNodeType = 'approval';
defaultApprovalName = node.unifiedJobTemplate.name;
defaultApprovalDescription = node.unifiedJobTemplate.description;
defaultApprovalTimeout = node.unifiedJobTemplate.timeout;
break;
default:
}
}
}
const [nodeType, setNodeType] = useState(defaultNodeType);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [showApprovalStep, setShowApprovalStep] = useState(
defaultNodeType === 'approval'
);
const [showResourceStep, setShowResourceStep] = useState(
defaultNodeResource ? true : false
);
const [showPreviewStep, setShowPreviewStep] = useState(
defaultNodeType === 'approval' || defaultNodeResource ? true : false
);
const [triggerNext, setTriggerNext] = useState(0);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalDescription, setApprovalDescription] = useState(
defaultApprovalDescription
);
const [approvalTimeout, setApprovalTimeout] = useState(
defaultApprovalTimeout
);
const handleSaveNode = () => {
const resource =
nodeType === 'approval'
? {
name: approvalName,
description: approvalDescription,
timeout: approvalTimeout,
type: 'workflow_approval_template',
}
: nodeResource;
// TODO: pick edgeType or linkType and be consistent across all files.
onSave({
nodeType,
edgeType: linkType,
nodeResource: resource,
});
};
const resourceSearch = queryParams => {
switch (nodeType) {
case 'inventory_source_sync':
return readInventorySources(queryParams);
case 'job_template':
return readJobTemplates(queryParams);
case 'project_sync':
return readProjects(queryParams);
case 'workflow_job_template':
return readWorkflowJobTemplates(queryParams);
default:
throw new Error(i18n._(t`Missing node type`));
}
};
const handleNextClick = activeStep => {
if (activeStep.key === 'node_type') {
if (
[
'inventory_source_sync',
'job_template',
'project_sync',
'workflow_job_template',
].includes(nodeType)
) {
setShowApprovalStep(false);
setShowResourceStep(true);
} else if (nodeType === 'approval') {
setShowResourceStep(false);
setShowApprovalStep(true);
}
setShowPreviewStep(true);
}
setTriggerNext(triggerNext + 1);
};
const handleNodeTypeChange = newNodeType => {
setNodeType(newNodeType);
setShowResourceStep(false);
setShowApprovalStep(false);
setShowPreviewStep(false);
setNodeResource(null);
setApprovalName('');
setApprovalDescription('');
setApprovalTimeout(0);
};
const steps = [
{
name: node ? i18n._(t`Node Type`) : i18n._(t`Run/Node Type`),
key: 'node_type',
component: (
<NodeTypeStep
nodeType={nodeType}
updateNodeType={handleNodeTypeChange}
askLinkType={askLinkType}
linkType={linkType}
updateLinkType={setLinkType}
/>
),
enableNext: nodeType !== null,
},
...(showResourceStep
? [
{
name: i18n._(t`Select Node Resource`),
key: 'node_resource',
enableNext: nodeResource !== null,
component: (
<NodeResourceStep
nodeType={nodeType}
search={resourceSearch}
nodeResource={nodeResource}
updateNodeResource={setNodeResource}
/>
),
},
]
: []),
...(showApprovalStep
? [
{
name: i18n._(t`Configure Approval`),
key: 'approval',
component: (
<NodeApprovalStep
name={approvalName}
updateName={setApprovalName}
description={approvalDescription}
updateDescription={setApprovalDescription}
timeout={approvalTimeout}
updateTimeout={setApprovalTimeout}
/>
),
enableNext: approvalName !== '',
},
]
: []),
...(showPreviewStep
? [
{
name: i18n._(t`Preview`),
key: 'preview',
component: (
<>
{nodeType === 'approval' && (
<ApprovalPreviewStep
name={approvalName}
description={approvalDescription}
timeout={approvalTimeout}
linkType={linkType}
/>
)}
{nodeType === 'job_template' && (
<JobTemplatePreviewStep
jobTemplate={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'inventory_source_sync' && (
<InventorySyncPreviewStep
inventorySource={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'project_sync' && (
<ProjectSyncPreviewStep
project={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'workflow_job_template' && (
<WorkflowJobTemplatePreviewStep
workflowJobTemplate={nodeResource}
linkType={linkType}
/>
)}
</>
),
enableNext: true,
},
]
: []),
];
steps.forEach((step, n) => {
step.id = n + 1;
});
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack, onClose }) => (
<>
<NodeNextButton
triggerNext={triggerNext}
activeStep={activeStep}
onNext={onNext}
onClick={handleNextClick}
/>
{activeStep && activeStep.id !== 1 && (
<Button variant="secondary" onClick={onBack}>
{i18n._(t`Back`)}
</Button>
)}
<Button variant="link" onClick={onClose}>
{i18n._(t`Cancel`)}
</Button>
</>
)}
</WizardContextConsumer>
</WizardFooter>
);
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
steps={steps}
title={title}
onClose={onClose}
onSave={handleSaveNode}
footer={CustomFooter}
/>
);
}
export default withI18n()(NodeModal);

View File

@ -0,0 +1,26 @@
import React, { useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) {
useEffect(() => {
if (!triggerNext) {
return;
}
onNext();
}, [triggerNext]);
return (
<Button
variant="primary"
type="submit"
onClick={() => onClick(activeStep)}
isDisabled={!activeStep.enableNext}
>
{activeStep.key === 'preview' ? i18n._(t`Save`) : i18n._(t`Next`)}
</Button>
);
}
export default withI18n()(NodeNextButton);

View File

@ -0,0 +1,120 @@
import React, { Fragment, useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { Title } from '@patternfly/react-core';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
import SelectedList from '@components/SelectedList';
const QS_CONFIG = getQSConfig('node_resource', {
page: 1,
page_size: 5,
order_by: 'name',
});
function NodeTypeStep({
i18n,
search,
nodeType,
nodeResource,
updateNodeResource,
}) {
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [rowCount, setRowCount] = useState(0);
const [rows, setRows] = useState([]);
let headerText = '';
switch (nodeType) {
case 'inventory_source_sync':
headerText = i18n._(t`Inventory Sources`);
break;
case 'job_template':
headerText = i18n._(t`Job Templates`);
break;
case 'project_sync':
headerText = i18n._(t`Projects`);
break;
case 'workflow_job_template':
headerText = i18n._(t`Workflow Job Templates`);
break;
default:
break;
}
const fetchRows = queryString => {
const params = parseQueryString(QS_CONFIG, queryString);
return search(params);
};
useEffect(() => {
async function fetchData() {
try {
const {
data: { count, results },
} = await fetchRows(location.node_resource);
setRows(results);
setRowCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [location]);
return (
<Fragment>
<Title headingLevel="h1" size="xl">
{headerText}
</Title>
<p>{i18n._(t`Select a resource to be executed from the list below.`)}</p>
{nodeResource && (
<SelectedList
displayKey="name"
label={i18n._(t`Selected`)}
onRemove={() => updateNodeResource(null)}
selected={[nodeResource]}
/>
)}
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={rows}
itemCount={rowCount}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
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}
/>
</Fragment>
);
}
export default withI18n()(NodeTypeStep);

View File

@ -0,0 +1,105 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Title } from '@patternfly/react-core';
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%;
margin: 20px 0px;
`;
function NodeTypeStep({
i18n,
nodeType,
updateNodeType,
linkType,
updateLinkType,
askLinkType,
}) {
return (
<div>
{askLinkType && (
<>
<Title headingLevel="h1" size="xl">
{i18n._(t`Run`)}
</Title>
<p>
{i18n._(
t`Specify the conditions under which this node should be executed`
)}
</p>
<Grid>
<SelectableCard
isSelected={linkType === 'success'}
label={i18n._(t`On Success`)}
description={i18n._(
t`Execute when the parent node results in a successful state.`
)}
onClick={() => updateLinkType('success')}
/>
<SelectableCard
isSelected={linkType === 'failure'}
label={i18n._(t`On Failure`)}
description={i18n._(
t`Execute when the parent node results in a failure state.`
)}
onClick={() => updateLinkType('failure')}
/>
<SelectableCard
isSelected={linkType === 'always'}
label={i18n._(t`Always`)}
description={i18n._(
t`Execute regardless of the parent node's final state.`
)}
onClick={() => updateLinkType('always')}
/>
</Grid>
</>
)}
<Title headingLevel="h1" size="xl">
{i18n._(t`Node Type`)}
</Title>
<Grid>
<SelectableCard
isSelected={nodeType === 'job_template'}
label={i18n._(t`Job Template`)}
description={i18n._(t`Execute a job template.`)}
onClick={() => updateNodeType('job_template')}
/>
<SelectableCard
isSelected={nodeType === 'workflow_job_template'}
label={i18n._(t`Workflow Job Template`)}
description={i18n._(t`Execute a workflow job template.`)}
onClick={() => updateNodeType('workflow_job_template')}
/>
<SelectableCard
isSelected={nodeType === 'project_sync'}
label={i18n._(t`Project Sync`)}
description={i18n._(t`Execute a project sync.`)}
onClick={() => updateNodeType('project_sync')}
/>
<SelectableCard
isSelected={nodeType === 'inventory_source_sync'}
label={i18n._(t`Inventory Source Sync`)}
description={i18n._(t`Execute an inventory source sync.`)}
onClick={() => updateNodeType('inventory_source_sync')}
/>
<SelectableCard
isSelected={nodeType === 'approval'}
label={i18n._(t`Approval`)}
description={i18n._(t`Pause the workflow and wait for approval.`)}
onClick={() => updateNodeType('approval')}
/>
</Grid>
</div>
);
}
export default withI18n()(NodeTypeStep);

View File

@ -0,0 +1,39 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function ProjectPreviewStep({ i18n, project, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Project Sync Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={project.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(ProjectPreviewStep);

View File

@ -0,0 +1,43 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function WorkflowJobTemplatePreviewStep({
i18n,
workflowJobTemplate,
linkType,
}) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Workflow Job Template Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={workflowJobTemplate.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(WorkflowJobTemplatePreviewStep);

View File

@ -0,0 +1,43 @@
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';
function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) {
return (
<Modal
width={600}
isOpen={true}
title={i18n._(t`Warning: Unsaved Changes`)}
onClose={onCancel}
actions={[
<Button
key="exit"
variant="danger"
aria-label={i18n._(t`Exit`)}
onClick={onExit}
>
{i18n._(t`Exit`)}
</Button>,
<Button
key="save"
variant="primary"
aria-label={i18n._(t`Save & Exit`)}
onClick={onSaveAndExit}
>
{i18n._(t`Save & Exit`)}
</Button>,
]}
>
<p>
<Trans>
Are you sure you want to exit the Workflow Creator without saving your
changes?
</Trans>
</p>
</Modal>
);
}
export default withI18n()(UnsavedChangesModal);

View File

@ -1,15 +1,26 @@
import React, { Fragment, useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
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 VisualizerGraph from './VisualizerGraph';
import VisualizerStartScreen from './VisualizerStartScreen';
import VisualizerToolbar from './VisualizerToolbar';
import { WorkflowJobTemplatesAPI } from '@api';
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
import {
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplatesAPI,
WorkflowJobTemplateNodesAPI,
} from '@api';
const CenteredContent = styled.div`
display: flex;
@ -25,7 +36,11 @@ const Wrapper = styled.div`
height: 100%;
`;
const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
const fetchWorkflowNodes = async (
templateId,
pageNo = 1,
workflowNodes = []
) => {
try {
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
page_size: 200,
@ -35,36 +50,111 @@ const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
return await fetchWorkflowNodes(
templateId,
pageNo + 1,
nodes.concat(data.results)
workflowNodes.concat(data.results)
);
}
return nodes.concat(data.results);
return workflowNodes.concat(data.results);
} catch (error) {
throw error;
}
};
function Visualizer({ template, i18n }) {
function Visualizer({ history, template, i18n }) {
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [graphLinks, setGraphLinks] = useState([]);
// We'll also need to store the original set of nodes...
const [graphNodes, setGraphNodes] = useState([]);
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 [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 [nextNodeId, setNextNodeId] = useState(0);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false);
const [showDeleteAllNodesModal, setShowDeleteAllNodesModal] = useState(false);
const [showKey, setShowKey] = useState(false);
const [showTools, setShowTools] = useState(false);
const startAddNode = (sourceNodeId, targetNodeId = null) => {
setAddNodeSource(sourceNodeId);
setAddNodeTarget(targetNodeId);
};
const finishAddingNode = newNode => {
const newNodes = [...nodes];
const newLinks = [...links];
newNodes.push({
id: nextNodeId,
type: 'node',
unifiedJobTemplate: newNode.nodeResource,
});
// Ensures that root nodes appear to always run
// after "START"
if (addNodeSource === 1) {
newNode.edgeType = 'always';
}
newLinks.push({
source: { id: addNodeSource },
target: { id: nextNodeId },
edgeType: newNode.edgeType,
type: 'link',
});
if (addNodeTarget) {
newLinks.forEach(linkToCompare => {
if (
linkToCompare.source.id === addNodeSource &&
linkToCompare.target.id === addNodeTarget
) {
linkToCompare.source = { id: nextNodeId };
}
});
}
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setAddNodeSource(null);
setAddNodeTarget(null);
setNextNodeId(nextNodeId + 1);
setNodes(newNodes);
setLinks(newLinks);
};
const startEditNode = nodeToEdit => {
setNodeToEdit(nodeToEdit);
};
const finishEditingNode = editedNode => {
const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
matchingNode.isEdited = true;
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setNodeToEdit(null);
setNodes(newNodes);
};
const cancelNodeForm = () => {
setAddNodeSource(null);
setAddNodeTarget(null);
setNodeToEdit(null);
};
const deleteNode = () => {
const nodeId = nodeToDelete.id;
const newGraphNodes = [...graphNodes];
const newGraphLinks = [...graphLinks];
const newNodes = [...nodes];
const newLinks = [...links];
// Remove the node from the array
for (let i = newGraphNodes.length; i--; ) {
if (newGraphNodes[i].id === nodeId) {
newGraphNodes.splice(i, 1);
i = 0;
}
}
newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
// Update the links
const parents = [];
@ -72,8 +162,8 @@ function Visualizer({ template, i18n }) {
const linkParentMapping = {};
// Remove any links that reference this node
for (let i = newGraphLinks.length; i--; ) {
const link = newGraphLinks[i];
for (let i = newLinks.length; i--; ) {
const link = newLinks[i];
if (!linkParentMapping[link.target.id]) {
linkParentMapping[link.target.id] = [];
@ -87,7 +177,7 @@ function Visualizer({ template, i18n }) {
} else if (link.target.id === nodeId) {
parents.push(link.source.id);
}
newGraphLinks.splice(i, 1);
newLinks.splice(i, 1);
}
}
@ -98,7 +188,7 @@ function Visualizer({ template, i18n }) {
// We only want to create a link from the start node to this node if it
// doesn't have any other parents
if (linkParentMapping[child.id].length === 1) {
newGraphLinks.push({
newLinks.push({
source: { id: parentId },
target: { id: child.id },
edgeType: 'always',
@ -106,7 +196,7 @@ function Visualizer({ template, i18n }) {
});
}
} else if (!linkParentMapping[child.id].includes(parentId)) {
newGraphLinks.push({
newLinks.push({
source: { id: parentId },
target: { id: child.id },
edgeType: child.edgeType,
@ -117,51 +207,483 @@ function Visualizer({ template, i18n }) {
});
// need to track that this node has been deleted if it's not new
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setNodeToDelete(null);
setGraphNodes(newGraphNodes);
setGraphLinks(newGraphLinks);
setNodes(newNodes);
setLinks(newLinks);
};
const updateLink = edgeType => {
const newLinks = [...links];
newLinks.forEach(link => {
if (
link.source.id === linkToEdit.source.id &&
link.target.id === linkToEdit.target.id
) {
link.edgeType = edgeType;
}
});
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setLinkToEdit(null);
setLinks(newLinks);
};
const startDeleteLink = link => {
let parentMap = {};
links.forEach(link => {
if (!parentMap[link.target.id]) {
parentMap[link.target.id] = [];
}
parentMap[link.target.id].push(link.source.id);
});
link.isConvergenceLink = parentMap[link.target.id].length > 1;
setLinkToDelete(link);
};
const deleteLink = () => {
const newLinks = [...links];
for (let i = newLinks.length; i--; ) {
const link = newLinks[i];
if (
link.source.id === linkToDelete.source.id &&
link.target.id === linkToDelete.target.id
) {
newLinks.splice(i, 1);
}
}
if (!linkToDelete.isConvergenceLink) {
// Add a new link from the start node to the orphaned node
newLinks.push({
source: {
id: 1,
},
target: {
id: linkToDelete.target.id,
},
edgeType: 'always',
type: 'link',
});
}
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setLinkToDelete(null);
setLinks(newLinks);
};
const selectSourceNodeForLinking = sourceNode => {
const newNodes = [...nodes];
let parentMap = {};
let invalidLinkTargetIds = [];
// Find and mark any ancestors as disabled to prevent cycles
links.forEach(link => {
// id=1 is our artificial root node so we don't care about that
if (link.source.id !== 1) {
if (link.source.id === sourceNode.id) {
// Disables direct children from the add link process
invalidLinkTargetIds.push(link.target.id);
}
if (!parentMap[link.target.id]) {
parentMap[link.target.id] = [];
}
parentMap[link.target.id].push(link.source.id);
}
});
let getAncestors = id => {
if (parentMap[id]) {
parentMap[id].forEach(parentId => {
invalidLinkTargetIds.push(parentId);
getAncestors(parentId);
});
}
};
getAncestors(sourceNode.id);
// Filter out the duplicates
invalidLinkTargetIds
.filter((element, index, array) => index === array.indexOf(element))
.forEach(ancestorId => {
newNodes.forEach(node => {
if (node.id === ancestorId) {
node.isInvalidLinkTarget = true;
}
});
});
setAddLinkSourceNode(sourceNode);
setAddingLink(true);
setNodes(newNodes);
};
const selectTargetNodeForLinking = targetNode => {
setAddLinkTargetNode(targetNode);
};
const addLink = edgeType => {
const newLinks = [...links];
const newNodes = [...nodes];
newNodes.forEach(node => {
node.isInvalidLinkTarget = false;
});
newLinks.push({
source: { id: addLinkSourceNode.id },
target: { id: addLinkTargetNode.id },
edgeType,
type: 'link',
});
newLinks.forEach((link, index) => {
if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) {
newLinks.splice(index, 1);
}
});
if (!unsavedChanges) {
setUnsavedChanges(true);
}
setAddLinkSourceNode(null);
setAddLinkTargetNode(null);
setAddingLink(false);
setLinks(newLinks);
};
const cancelNodeLink = () => {
const newNodes = [...nodes];
newNodes.forEach(node => {
node.isInvalidLinkTarget = false;
});
setAddLinkSourceNode(null);
setAddLinkTargetNode(null);
setAddingLink(false);
setNodes(newNodes);
};
const deleteAllNodes = () => {
setAddLinkSourceNode(null);
setAddLinkTargetNode(null);
setAddingLink(false);
setNodes(
nodes.map(node => {
if (node.id !== 1) {
node.isDeleted = true;
}
return node;
})
);
setLinks([]);
setShowDeleteAllNodesModal(false);
};
const handleVisualizerClose = () => {
if (unsavedChanges) {
setShowUnsavedChangesModal(true);
} else {
history.push(`/templates/workflow_job_template/${template.id}/details`);
}
};
const handleVisualizerSave = async () => {
const nodeRequests = [];
const approvalTemplateRequests = [];
const originalLinkMap = {};
const deletedNodeIds = [];
nodes.forEach(node => {
if (node.originalNodeObject && !node.isDeleted) {
const {
id,
success_nodes,
failure_nodes,
always_nodes,
} = node.originalNodeObject;
originalLinkMap[node.id] = {
id,
success_nodes,
failure_nodes,
always_nodes,
};
}
if (node.id !== 1) {
// node with id=1 is the artificial start node
if (node.isDeleted && node.originalNodeObject) {
deletedNodeIds.push(node.originalNodeObject.id);
nodeRequests.push(
WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id)
);
} else if (!node.isDeleted && !node.originalNodeObject) {
if (node.unifiedJobTemplate.type === 'workflow_approval_template') {
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {}).then(
({ data }) => {
node.originalNodeObject = data;
originalLinkMap[node.id] = {
id: data.id,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
};
approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
data.id,
{
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
}
)
);
}
)
);
} else {
nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {
unified_job_template: node.unifiedJobTemplate.id,
}).then(({ data }) => {
node.originalNodeObject = data;
originalLinkMap[node.id] = {
id: data.id,
success_nodes: [],
failure_nodes: [],
always_nodes: [],
};
})
);
}
} else if (node.isEdited) {
if (
node.unifiedJobTemplate &&
(node.unifiedJobTemplate.unified_job_type === 'workflow_approval' ||
node.unifiedJobTemplate.type === 'workflow_approval_template')
) {
if (
node.originalNodeObject.summary_fields.unified_job_template
.unified_job_type === 'workflow_approval'
) {
approvalTemplateRequests.push(
WorkflowApprovalTemplatesAPI.update(
node.originalNodeObject.summary_fields.unified_job_template
.id,
{
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
}
)
);
} else {
approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
node.originalNodeObject.id,
{
name: node.unifiedJobTemplate.name,
description: node.unifiedJobTemplate.description,
timeout: node.unifiedJobTemplate.timeout,
}
)
);
}
} else {
nodeRequests.push(
WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, {
unified_job_template: node.unifiedJobTemplate.id,
})
);
}
}
}
});
// TODO: error handling?
await Promise.all(nodeRequests);
await Promise.all(approvalTemplateRequests);
const associateRequests = [];
const disassociateRequests = [];
const linkMap = {};
const newLinks = [];
links.forEach(link => {
if (link.source.id !== 1) {
const realLinkSourceId = originalLinkMap[link.source.id].id;
const realLinkTargetId = originalLinkMap[link.target.id].id;
if (!linkMap[realLinkSourceId]) {
linkMap[realLinkSourceId] = {};
}
linkMap[realLinkSourceId][realLinkTargetId] = link.edgeType;
switch (link.edgeType) {
case 'success':
if (
!originalLinkMap[link.source.id].success_nodes.includes(
originalLinkMap[link.target.id].id
)
) {
newLinks.push(link);
}
break;
case 'failure':
if (
!originalLinkMap[link.source.id].failure_nodes.includes(
originalLinkMap[link.target.id].id
)
) {
newLinks.push(link);
}
break;
case 'always':
if (
!originalLinkMap[link.source.id].always_nodes.includes(
originalLinkMap[link.target.id].id
)
) {
newLinks.push(link);
}
break;
default:
}
}
});
for (const [nodeId, node] of Object.entries(originalLinkMap)) {
node.success_nodes.forEach(successNodeId => {
if (
!deletedNodeIds.includes(successNodeId) &&
(!linkMap[node.id] ||
!linkMap[node.id][successNodeId] ||
linkMap[node.id][successNodeId] !== 'success')
) {
disassociateRequests.push(
WorkflowJobTemplateNodesAPI.disassociateSuccessNode(
node.id,
successNodeId
)
);
}
});
node.failure_nodes.forEach(failureNodeId => {
if (
!deletedNodeIds.includes(failureNodeId) &&
(!linkMap[node.id] ||
!linkMap[node.id][failureNodeId] ||
linkMap[node.id][failureNodeId] !== 'failure')
) {
disassociateRequests.push(
WorkflowJobTemplateNodesAPI.disassociateFailuresNode(
node.id,
failureNodeId
)
);
}
});
node.always_nodes.forEach(alwaysNodeId => {
if (
!deletedNodeIds.includes(alwaysNodeId) &&
(!linkMap[node.id] ||
!linkMap[node.id][alwaysNodeId] ||
linkMap[node.id][alwaysNodeId] !== 'always')
) {
disassociateRequests.push(
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode(
node.id,
alwaysNodeId
)
);
}
});
}
// TODO: error handling?
await Promise.all(disassociateRequests);
newLinks.forEach(link => {
switch (link.edgeType) {
case 'success':
associateRequests.push(
WorkflowJobTemplateNodesAPI.associateSuccessNode(
originalLinkMap[link.source.id].id,
originalLinkMap[link.target.id].id
)
);
break;
case 'failure':
associateRequests.push(
WorkflowJobTemplateNodesAPI.associateFailureNode(
originalLinkMap[link.source.id].id,
originalLinkMap[link.target.id].id
)
);
break;
case 'always':
associateRequests.push(
WorkflowJobTemplateNodesAPI.associateAlwaysNode(
originalLinkMap[link.source.id].id,
originalLinkMap[link.target.id].id
)
);
break;
default:
}
});
// TODO: error handling?
await Promise.all(associateRequests);
// Some nodes (both new and edited) are going to need a followup request to
// either create or update an approval job template. This has to happen
// after the node has been created
history.push(`/templates/workflow_job_template/${template.id}/details`);
};
useEffect(() => {
const buildGraphArrays = nodes => {
const buildGraphArrays = workflowNodes => {
const nonRootNodeIds = [];
const allNodeIds = [];
const arrayOfLinksForChart = [];
const nodeIdToChartNodeIdMapping = {};
const chartNodeIdToIndexMapping = {};
const nodeRef = {};
let nodeIdCounter = 1;
const arrayOfNodesForChart = [
{
id: nodeIdCounter,
id: 1,
unifiedJobTemplate: {
name: i18n._(t`START`),
},
type: 'node',
},
];
nodeIdCounter++;
// Assign each node an ID - 0 is reserved for the start node. We need to
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
// ID returned by the api won't do
nodes.forEach(node => {
workflowNodes.forEach(node => {
node.workflowMakerNodeId = nodeIdCounter;
nodeRef[nodeIdCounter] = {
originalNodeObject: node,
};
const nodeObj = {
index: nodeIdCounter - 1,
id: nodeIdCounter,
type: 'node',
originalNodeObject: node,
};
if (node.summary_fields.job) {
nodeObj.job = node.summary_fields.job;
}
if (node.summary_fields.unified_job_template) {
nodeRef[nodeIdCounter].unifiedJobTemplate =
node.summary_fields.unified_job_template;
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
}
@ -172,7 +694,7 @@ function Visualizer({ template, i18n }) {
nodeIdCounter++;
});
nodes.forEach(node => {
workflowNodes.forEach(node => {
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
node.success_nodes.forEach(nodeId => {
const targetIndex =
@ -226,14 +748,15 @@ function Visualizer({ template, i18n }) {
});
});
setGraphNodes(arrayOfNodesForChart);
setGraphLinks(arrayOfLinksForChart);
setNodes(arrayOfNodesForChart);
setLinks(arrayOfLinksForChart);
setNextNodeId(nodeIdCounter);
};
async function fetchData() {
try {
const nodes = await fetchWorkflowNodes(template.id);
buildGraphArrays(nodes);
const workflowNodes = await fetchWorkflowNodes(template.id);
buildGraphArrays(workflowNodes);
} catch (error) {
setContentError(error);
} finally {
@ -245,9 +768,10 @@ function Visualizer({ template, i18n }) {
// Update positions of nodes/links
useEffect(() => {
if (graphNodes) {
if (nodes) {
const newNodePositions = {};
const g = layoutGraph(graphNodes, graphLinks);
const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
const g = layoutGraph(nonDeletedNodes, links);
g.nodes().forEach(node => {
newNodePositions[node] = g.node(node);
@ -255,7 +779,7 @@ function Visualizer({ template, i18n }) {
setNodePositions(newNodePositions);
}
}, [graphLinks, graphNodes]);
}, [links, nodes]);
if (isLoading) {
return (
@ -276,17 +800,38 @@ function Visualizer({ template, i18n }) {
return (
<Fragment>
<Wrapper>
<VisualizerToolbar template={template} />
{graphLinks.length > 0 ? (
<VisualizerToolbar
nodes={nodes}
template={template}
onClose={handleVisualizerClose}
onSave={handleVisualizerSave}
onDeleteAllClick={() => setShowDeleteAllNodesModal(true)}
onKeyToggle={() => setShowKey(!showKey)}
keyShown={showKey}
onToolsToggle={() => setShowTools(!showTools)}
toolsShown={showTools}
/>
{links.length > 0 ? (
<VisualizerGraph
links={graphLinks}
nodes={graphNodes}
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}
addingLink={addingLink}
addLinkSourceNode={addLinkSourceNode}
showKey={showKey}
showTools={showTools}
/>
) : (
<VisualizerStartScreen />
<VisualizerStartScreen onStartClick={startAddNode} />
)}
</Wrapper>
<NodeDeleteModal
@ -294,8 +839,74 @@ function Visualizer({ template, i18n }) {
onConfirm={deleteNode}
onCancel={() => setNodeToDelete(null)}
/>
{linkToDelete && (
<LinkDeleteModal
linkToDelete={linkToDelete}
onConfirm={deleteLink}
onCancel={() => setLinkToDelete(null)}
/>
)}
{linkToEdit && (
<LinkModal
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}
/>
)}
{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}
/>
)}
{addNodeSource && (
<NodeModal
askLinkType={addNodeSource !== 1}
title={i18n._(t`Add Node`)}
onClose={() => cancelNodeForm()}
onSave={finishAddingNode}
/>
)}
{nodeToEdit && (
<NodeModal
askLinkType={false}
node={nodeToEdit}
title={i18n._(t`Edit Node`)}
onClose={() => cancelNodeForm()}
onSave={finishEditingNode}
/>
)}
{showUnsavedChangesModal && (
<UnsavedChangesModal
onCancel={() => setShowUnsavedChangesModal(false)}
onExit={() =>
history.push(
`/templates/workflow_job_template/${template.id}/details`
)
}
onSaveAndExit={() => handleVisualizerSave()}
/>
)}
{showDeleteAllNodesModal && (
<DeleteAllNodesModal
onCancel={() => setShowDeleteAllNodesModal(false)}
onConfirm={() => deleteAllNodes()}
/>
)}
</Fragment>
);
}
export default withI18n()(Visualizer);
export default withI18n()(withRouter(Visualizer));

View File

@ -1,6 +1,13 @@
import React, { Fragment, useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import * as d3 from 'd3';
import { calcZoomAndFit } from '@util/workflow';
import {
calcZoomAndFit,
constants as wfConstants,
getZoomTranslate,
} from '@util/workflow';
import {
WorkflowHelp,
WorkflowLinkHelp,
@ -10,21 +17,103 @@ import {
VisualizerLink,
VisualizerNode,
VisualizerStartNode,
VisualizerKey,
VisualizerTools,
} from '@screens/Template/WorkflowJobTemplateVisualizer';
function VizualizerGraph({
const PotentialLink = styled.polyline`
pointer-events: none;
`;
const WorkflowSVG = styled.svg`
display: flex;
height: 100%;
background-color: #f6f6f6;
`;
// const KeyWrapper = styled.div`
// position: absolute;
// right: 20px;
// top: 76px;
// `;
// const ToolsWrapper = styled.div`
// position: absolute;
// right: 200px;
// top: 76px;
// `;
function VisualizerGraph({
links,
nodes,
readOnly,
nodePositions,
onDeleteNodeClick,
onAddNodeClick,
onEditNodeClick,
onLinkEditClick,
onDeleteLinkClick,
onStartAddLinkClick,
onConfirmAddLinkClick,
onCancelAddLinkClick,
addingLink,
addLinkSourceNode,
showKey,
showTools,
i18n,
}) {
const [helpText, setHelpText] = useState(null);
const [nodeHelp, setNodeHelp] = useState();
const [linkHelp, setLinkHelp] = useState();
const [zoomPercentage, setZoomPercentage] = useState(100);
const svgRef = useRef(null);
const gRef = useRef(null);
const drawPotentialLinkToNode = node => {
if (node.id !== addLinkSourceNode.id) {
const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
const sourceNodeY =
nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
const targetNodeX = nodePositions[node.id].x;
const targetNodeY = nodePositions[node.id].y - nodePositions[1].y;
const startX = sourceNodeX + wfConstants.nodeW;
const startY = sourceNodeY + wfConstants.nodeH / 2;
const finishX = targetNodeX;
const finishY = targetNodeY + wfConstants.nodeH / 2;
d3.select('#workflow-potentialLink')
.attr('points', `${startX},${startY} ${finishX},${finishY}`)
.raise();
}
};
const handleBackgroundClick = () => {
setHelpText(null);
onCancelAddLinkClick();
};
const drawPotentialLinkToCursor = e => {
const currentTransform = d3.zoomTransform(d3.select(gRef.current).node());
const rect = e.target.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
const sourceNodeY =
nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
const startX = sourceNodeX + wfConstants.nodeW;
const startY = sourceNodeY + wfConstants.nodeH / 2;
d3.select('#workflow-potentialLink')
.attr(
'points',
`${startX},${startY} ${mouseX / currentTransform.k -
currentTransform.x / currentTransform.k},${mouseY /
currentTransform.k -
currentTransform.y / currentTransform.k}`
)
.raise();
};
// This is the zoom function called by using the mousewheel/click and drag
const zoom = () => {
const translation = [d3.event.transform.x, d3.event.transform.y];
@ -32,6 +121,74 @@ function VizualizerGraph({
'transform',
`translate(${translation}) scale(${d3.event.transform.k})`
);
setZoomPercentage(d3.event.transform.k * 100);
};
const handlePan = direction => {
let { x: xPos, y: yPos, k: currentScale } = d3.zoomTransform(
d3.select(svgRef.current).node()
);
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
@ -46,12 +203,17 @@ function VizualizerGraph({
// 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.
@ -61,7 +223,7 @@ function VizualizerGraph({
}, []);
return (
<Fragment>
<>
{(helpText || nodeHelp || linkHelp) && (
<WorkflowHelp>
{helpText && <p>{helpText}</p>}
@ -69,11 +231,38 @@ function VizualizerGraph({
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
</WorkflowHelp>
)}
<svg
id="workflow-svg"
ref={svgRef}
css="display: flex; height: 100%; background-color: #f6f6f6;"
>
<WorkflowSVG id="workflow-svg" ref={svgRef} css="">
<defs>
<marker
id="workflow-triangle"
className="WorkflowChart-noPointerEvents"
viewBox="0 -5 10 10"
refX="10"
markerUnits="strokeWidth"
markerWidth="6"
markerHeight="6"
orient="auto"
>
<path d="M0,-5L10,0L0,5" fill="#93969A" />
</marker>
</defs>
<rect
width="100%"
height="100%"
opacity="0"
id="workflow-backround"
{...(addingLink && {
onMouseMove: e => drawPotentialLinkToCursor(e),
onMouseOver: () =>
setHelpText(
i18n._(
t`Click an available node to create a new link. Click outside the graph to cancel.`
)
),
onMouseOut: () => setHelpText(null),
onClick: () => handleBackgroundClick(),
})}
/>
<g id="workflow-g" ref={gRef}>
{nodePositions && [
<VisualizerStartNode
@ -81,19 +270,33 @@ function VizualizerGraph({
nodePositions={nodePositions}
readOnly={readOnly}
updateHelpText={setHelpText}
addingLink={addingLink}
onAddNodeClick={onAddNodeClick}
/>,
links.map(link => (
<VisualizerLink
key={`link-${link.source.id}-${link.target.id}`}
link={link}
nodePositions={nodePositions}
updateHelpText={setHelpText}
updateLinkHelp={setLinkHelp}
readOnly={readOnly}
/>
)),
links.map(link => {
if (
nodePositions[link.source.id] &&
nodePositions[link.target.id]
) {
return (
<VisualizerLink
key={`link-${link.source.id}-${link.target.id}`}
link={link}
nodePositions={nodePositions}
updateHelpText={setHelpText}
updateLinkHelp={setLinkHelp}
readOnly={readOnly}
onLinkEditClick={onLinkEditClick}
onDeleteLinkClick={onDeleteLinkClick}
addingLink={addingLink}
onAddNodeClick={onAddNodeClick}
/>
);
}
return null;
}),
nodes.map(node => {
if (node.id > 1) {
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
return (
<VisualizerNode
key={`node-${node.id}`}
@ -102,17 +305,49 @@ function VizualizerGraph({
updateHelpText={setHelpText}
updateNodeHelp={setNodeHelp}
readOnly={readOnly}
onAddNodeClick={onAddNodeClick}
onEditNodeClick={onEditNodeClick}
onDeleteNodeClick={onDeleteNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
addingLink={addingLink}
isAddLinkSourceNode={
addLinkSourceNode && addLinkSourceNode.id === node.id
}
{...(addingLink && {
onMouseOver: () => drawPotentialLinkToNode(node),
})}
/>
);
}
return null;
}),
]}
{addingLink && (
<PotentialLink
id="workflow-potentialLink"
strokeDasharray="5,5"
strokeWidth="2"
stroke="#93969A"
markerEnd="url(#workflow-triangle)"
/>
)}
</g>
</svg>
</Fragment>
</WorkflowSVG>
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
{showTools && (
<VisualizerTools
zoomPercentage={zoomPercentage}
onZoomChange={handleZoomChange}
onFitGraph={handleFitGraph}
onPan={handlePan}
onPanToMiddle={handlePanToMiddle}
/>
)}
{showKey && <VisualizerKey />}
</div>
</>
);
}
export default VizualizerGraph;
export default withI18n()(VisualizerGraph);

View File

@ -0,0 +1,116 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
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;
margin-left: 20px;
`;
const Header = styled.div`
padding: 10px;
border-bottom: 1px solid #c7c7c7;
`;
const Key = styled.ul`
padding: 5px 10px;
li {
padding: 5px 0px;
display: flex;
align-items: center;
}
`;
const NodeTypeLetter = styled.div`
font-size: 10px;
color: white;
text-align: center;
line-height: 20px;
background-color: #393f43;
border-radius: 50%;
height: 20px;
width: 20px;
margin-right: 10px;
`;
const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
color: #f0ad4d;
margin-right: 10px;
height: 20px;
width: 20px;
`;
const Link = styled.div`
height: 5px;
width: 20px;
margin-right: 10px;
`;
const SuccessLink = styled(Link)`
background-color: #5cb85c;
`;
const FailureLink = styled(Link)`
background-color: #d9534f;
`;
const AlwaysLink = styled(Link)`
background-color: #337ab7;
`;
function VisualizerKey({ i18n }) {
return (
<Wrapper>
<Header>
<b>{i18n._(t`Key`)}</b>
</Header>
<Key>
<li>
<NodeTypeLetter>JT</NodeTypeLetter>
<span>{i18n._(t`Job Template`)}</span>
</li>
<li>
<NodeTypeLetter>W</NodeTypeLetter>
<span>{i18n._(t`Workflow`)}</span>
</li>
<li>
<NodeTypeLetter>I</NodeTypeLetter>
<span>{i18n._(t`Inventory Sync`)}</span>
</li>
<li>
<NodeTypeLetter>P</NodeTypeLetter>
<span>{i18n._(t`Project Sync`)}</span>
</li>
<li>
<NodeTypeLetter>
<PauseIcon />
</NodeTypeLetter>
<span>{i18n._(t`Approval`)}</span>
</li>
<li>
<StyledExclamationTriangleIcon />
<span>{i18n._(t`Warning`)}</span>
</li>
<li>
<SuccessLink />
<span>{i18n._(t`On Success`)}</span>
</li>
<li>
<FailureLink />
<span>{i18n._(t`On Failure`)}</span>
</li>
<li>
<AlwaysLink />
<span>{i18n._(t`Always`)}</span>
</li>
</Key>
</Wrapper>
);
}
export default withI18n()(VisualizerKey);

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
@ -12,6 +13,10 @@ import {
WorkflowActionTooltipItem,
} from '@components/Workflow';
const LinkG = styled.g`
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
`;
function VisualizerLink({
link,
nodePositions,
@ -19,6 +24,10 @@ function VisualizerLink({
updateHelpText,
updateLinkHelp,
i18n,
onLinkEditClick,
onDeleteLinkClick,
addingLink,
onAddNodeClick,
}) {
const [hovering, setHovering] = useState(false);
const [pathD, setPathD] = useState();
@ -30,6 +39,11 @@ function VisualizerLink({
<WorkflowActionTooltipItem
id="link-add-node"
key="add"
onClick={() => {
updateHelpText(null);
setHovering(false);
onAddNodeClick(link.source.id, link.target.id);
}}
onMouseEnter={() =>
updateHelpText(i18n._(t`Add a new node between these two nodes`))
}
@ -49,6 +63,7 @@ function VisualizerLink({
key="edit"
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => onLinkEditClick(link)}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
@ -57,6 +72,7 @@ function VisualizerLink({
key="delete"
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => onDeleteLinkClick(link)}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -98,11 +114,12 @@ function VisualizerLink({
}, [link, nodePositions]);
return (
<g
<LinkG
className="WorkflowGraph-link"
id={`link-${link.source.id}-${link.target.id}`}
onMouseEnter={handleLinkMouseEnter}
onMouseLeave={handleLinkMouseLeave}
ignorePointerEvents={addingLink}
>
<polygon
id={`link-${link.source.id}-${link.target.id}-overlay`}
@ -129,7 +146,7 @@ function VisualizerLink({
actions={tooltipActions}
/>
)}
</g>
</LinkG>
);
}

View File

@ -16,14 +16,16 @@ import {
WorkflowNodeTypeLetter,
} from '@components/Workflow';
// dont need this in this component
const NodeG = styled.g`
pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
cursor: ${props => (props.job ? 'pointer' : 'default')};
`;
const NodeContents = styled.foreignObject`
font-size: 13px;
padding: 0px 10px;
background-color: ${props =>
props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'};
`;
const NodeDefaultLabel = styled.p`
@ -42,6 +44,13 @@ function VisualizerNode({
readOnly,
i18n,
onDeleteNodeClick,
onStartAddLinkClick,
onConfirmAddLinkClick,
addingLink,
onMouseOver,
isAddLinkSourceNode,
onAddNodeClick,
onEditNodeClick,
}) {
const [hovering, setHovering] = useState(false);
@ -49,6 +58,29 @@ function VisualizerNode({
const nodeEl = document.getElementById(`node-${node.id}`);
nodeEl.parentNode.appendChild(nodeEl);
setHovering(true);
if (addingLink) {
updateHelpText(
node.isInvalidLinkTarget
? i18n._(
t`Invalid link target. Unable to link to children or ancestor nodes. Graph cycles are not supported.`
)
: i18n._(t`Click to create a new link to this node.`)
);
onMouseOver(node);
}
};
const handleNodeMouseLeave = () => {
setHovering(false);
if (addingLink) {
updateHelpText(null);
}
};
const handleNodeClick = () => {
if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) {
onConfirmAddLinkClick(node);
}
};
const viewDetailsAction = (
@ -70,6 +102,11 @@ function VisualizerNode({
key="add"
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onAddNodeClick(node.id);
}}
>
<PlusIcon />
</WorkflowActionTooltipItem>,
@ -79,6 +116,11 @@ function VisualizerNode({
key="edit"
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onEditNodeClick(node);
}}
>
<PencilAltIcon />
</WorkflowActionTooltipItem>,
@ -89,6 +131,11 @@ function VisualizerNode({
updateHelpText(i18n._(t`Link to an available node`))
}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onStartAddLinkClick(node);
}}
>
<LinkIcon />
</WorkflowActionTooltipItem>,
@ -97,7 +144,11 @@ function VisualizerNode({
key="delete"
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => onDeleteNodeClick(node)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onDeleteNodeClick(node);
}}
>
<TrashAltIcon />
</WorkflowActionTooltipItem>,
@ -109,23 +160,32 @@ function VisualizerNode({
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
.y - nodePositions[1].y})`}
job={node.job}
noPointerEvents={isAddLinkSourceNode}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={() => setHovering(false)}
onMouseLeave={handleNodeMouseLeave}
>
<rect
width={wfConstants.nodeW}
height={wfConstants.nodeH}
rx="2"
ry="2"
stroke="#93969A"
strokeWidth="2px"
stroke={
hovering && addingLink && !node.isInvalidLinkTarget
? '#007ABC'
: '#93969A'
}
strokeWidth="4px"
fill="#FFFFFF"
/>
<NodeContents
height="60"
width="180"
onMouseEnter={() => updateNodeHelp(node)}
onMouseLeave={() => updateNodeHelp(null)}
isInvalidLinkTarget={node.isInvalidLinkTarget}
{...(!addingLink && {
onMouseEnter: () => updateNodeHelp(node),
onMouseLeave: () => updateNodeHelp(null),
})}
onClick={() => handleNodeClick()}
>
<NodeDefaultLabel>
{node.unifiedJobTemplate
@ -134,7 +194,7 @@ function VisualizerNode({
</NodeDefaultLabel>
</NodeContents>
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
{hovering && (
{hovering && !addingLink && (
<WorkflowActionTooltip
pointX={wfConstants.nodeW}
pointY={wfConstants.nodeH / 2}

View File

@ -1,17 +1,25 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { PlusIcon } from '@patternfly/react-icons';
import { constants as wfConstants } from '@util/workflow';
import {
WorkflowActionTooltip,
WorkflowActionTooltipItem,
} from '@components/Workflow';
const StartG = styled.g`
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
`;
function VisualizerStartNode({
updateHelpText,
nodePositions,
readOnly,
i18n,
addingLink,
onAddNodeClick,
}) {
const [hovering, setHovering] = useState(false);
@ -22,11 +30,12 @@ function VisualizerStartNode({
};
return (
<g
<StartG
id="node-1"
transform={`translate(${nodePositions[1].x},0)`}
onMouseEnter={handleNodeMouseEnter}
onMouseLeave={() => setHovering(false)}
ignorePointerEvents={addingLink}
>
<rect
width={wfConstants.rootW}
@ -50,13 +59,18 @@ function VisualizerStartNode({
key="add"
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onAddNodeClick(1);
}}
>
<i className="pf-icon pf-icon-add-circle-o" />
<PlusIcon />
</WorkflowActionTooltipItem>,
]}
/>
)}
</g>
</StartG>
);
}

View File

@ -17,7 +17,6 @@ const StartPanel = styled.div`
padding: 60px 80px;
border: 1px solid #c7c7c7;
background-color: white;
color: var(--pf-global--Color--200);
text-align: center;
`;
@ -29,13 +28,17 @@ const StartPanelWrapper = styled.div`
background-color: #f6f6f6;
`;
function StartScreen({ i18n }) {
function StartScreen({ 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`)}>
<Button
variant="primary"
aria-label={i18n._(t`Start`)}
onClick={() => onStartClick(1)}
>
{i18n._(t`Start`)}
</Button>
</StartPanel>

View File

@ -1,8 +1,7 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Badge as PFBadge, Button } from '@patternfly/react-core';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import {
BookIcon,
CompassIcon,
@ -22,10 +21,34 @@ const Badge = styled(PFBadge)`
margin-left: 10px;
`;
function Toolbar({ history, i18n, template }) {
const handleVisualizerCancel = () => {
history.push(`/templates/workflow_job_template/${template.id}/details`);
};
const ActionButton = styled(Button)`
padding: 6px 10px;
margin: 0px 6px;
border: none;
&:hover {
background-color: #0066cc;
color: white;
}
&.pf-m-active {
background-color: #0066cc;
color: white;
}
`;
function Toolbar({
i18n,
template,
onClose,
onSave,
nodes = [],
onDeleteAllClick,
onKeyToggle,
keyShown,
onToolsToggle,
toolsShown,
}) {
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
return (
<div>
@ -52,35 +75,54 @@ function Toolbar({ history, i18n, template }) {
}}
>
<div>{i18n._(t`Total Nodes`)}</div>
<Badge isRead>0</Badge>
<Badge isRead>{totalNodes}</Badge>
<VerticalSeparator />
<Button variant="plain">
<CompassIcon />
</Button>
<Button variant="plain">
<WrenchIcon />
</Button>
<Button variant="plain">
<Tooltip content={i18n._(t`Toggle Key`)} position="bottom">
<ActionButton
variant="plain"
onClick={onKeyToggle}
isActive={keyShown}
>
<CompassIcon />
</ActionButton>
</Tooltip>
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
<ActionButton
variant="plain"
onClick={onToolsToggle}
isActive={toolsShown}
>
<WrenchIcon />
</ActionButton>
</Tooltip>
<ActionButton variant="plain" isDisabled>
<DownloadIcon />
</Button>
<Button variant="plain">
</ActionButton>
<ActionButton variant="plain" isDisabled>
<BookIcon />
</Button>
<Button variant="plain">
</ActionButton>
<ActionButton variant="plain" isDisabled>
<RocketIcon />
</Button>
<Button variant="plain">
<TrashAltIcon />
</Button>
</ActionButton>
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom">
<ActionButton
variant="plain"
isDisabled={totalNodes === 0}
aria-label={i18n._(t`Delete all nodes`)}
onClick={onDeleteAllClick}
>
<TrashAltIcon />
</ActionButton>
</Tooltip>
<VerticalSeparator />
<Button variant="primary" onClick={handleVisualizerCancel}>
<Button variant="primary" onClick={onSave}>
{i18n._(t`Save`)}
</Button>
<VerticalSeparator />
<Button
variant="plain"
aria-label={i18n._(t`Close`)}
onClick={handleVisualizerCancel}
onClick={onClose}
>
<TimesIcon />
</Button>
@ -90,4 +132,4 @@ function Toolbar({ history, i18n, template }) {
);
}
export default withI18n()(withRouter(Toolbar));
export default withI18n()(Toolbar);

View File

@ -0,0 +1,122 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Tooltip } from '@patternfly/react-core';
import {
CaretDownIcon,
CaretLeftIcon,
CaretRightIcon,
CaretUpIcon,
DesktopIcon,
HomeIcon,
MinusIcon,
PlusIcon,
} from '@patternfly/react-icons';
const Wrapper = styled.div`
border: 1px solid #c7c7c7;
background-color: white;
height: 135px;
`;
const Header = styled.div`
padding: 10px;
border-bottom: 1px solid #c7c7c7;
`;
const Pan = styled.div`
display: flex;
align-items: center;
`;
const PanCenter = styled.div`
display: flex;
flex-direction: column;
`;
const Tools = styled.div`
display: flex;
align-items: center;
padding: 20px;
`;
function VisualizerTools({
i18n,
zoomPercentage,
onZoomChange,
onFitGraph,
onPan,
onPanToMiddle,
}) {
const zoomIn = () => {
const newScale =
Math.ceil((zoomPercentage + 10) / 10) * 10 < 200
? Math.ceil((zoomPercentage + 10) / 10) * 10
: 200;
onZoomChange(newScale / 100);
};
const zoomOut = () => {
const newScale =
Math.floor((zoomPercentage - 10) / 10) * 10 > 10
? Math.floor((zoomPercentage - 10) / 10) * 10
: 10;
onZoomChange(newScale / 100);
};
return (
<Wrapper>
<Header>
<b>{i18n._(t`Tools`)}</b>
</Header>
<Tools>
<Tooltip
content={i18n._(t`Fit the graph to the available screen size`)}
position="bottom"
>
<DesktopIcon onClick={() => onFitGraph()} css="margin-right: 30px;" />
</Tooltip>
<Tooltip content={i18n._(t`Zoom Out`)} position="bottom">
<MinusIcon onClick={() => zoomOut()} css="margin-right: 10px;" />
</Tooltip>
<input
type="range"
id="zoom-slider"
value={zoomPercentage}
min="10"
max="200"
step="10"
onChange={event => onZoomChange(parseInt(event.target.value) / 100)}
></input>
<Tooltip content={i18n._(t`Zoom In`)} position="bottom">
<PlusIcon onClick={() => zoomIn()} css="margin: 0px 25px 0px 10px;" />
</Tooltip>
<Pan>
<Tooltip content={i18n._(t`Pan Left`)} position="left">
<CaretLeftIcon onClick={() => onPan('left')} />
</Tooltip>
<PanCenter>
<Tooltip content={i18n._(t`Pan Up`)} position="top">
<CaretUpIcon onClick={() => onPan('up')} />
</Tooltip>
<Tooltip
content={i18n._(t`Set zoom to 100% and center graph`)}
position="top"
>
<HomeIcon onClick={() => onPanToMiddle()} />
</Tooltip>
<Tooltip content={i18n._(t`Pan Down`)} position="bottom">
<CaretDownIcon onClick={() => onPan('down')} />
</Tooltip>
</PanCenter>
<Tooltip content={i18n._(t`Pan Right`)} position="right">
<CaretRightIcon onClick={() => onPan('right')} />
</Tooltip>
</Pan>
</Tools>
</Wrapper>
);
}
export default withI18n()(VisualizerTools);

View File

@ -5,3 +5,5 @@ 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';

View File

@ -11,12 +11,16 @@ export const constants = {
rootH: 40,
};
export function calcZoomAndFit(gRef) {
export function calcZoomAndFit(gRef, svgRef) {
const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node());
const gBoundingClientRect = d3
.select(gRef)
.node()
.getBoundingClientRect();
gBoundingClientRect.height = gBoundingClientRect.height / currentScale;
gBoundingClientRect.width = gBoundingClientRect.width / currentScale;
const gBBoxDimensions = d3
.select(gRef)
.node()
@ -45,7 +49,7 @@ export function calcZoomAndFit(gRef) {
yTranslate =
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
2 -
gBBoxDimensions.y * scaleToFit;
(gBBoxDimensions.y / currentScale) * scaleToFit;
}
return [scaleToFit, yTranslate];
@ -172,3 +176,29 @@ export function layoutGraph(nodes, links) {
return g;
}
export function getZoomTranslate(svgRef, newScale) {
const svgElement = document.getElementById('workflow-svg');
const svgBoundingClientRect = svgElement.getBoundingClientRect();
const current = d3.zoomTransform(d3.select(svgRef).node());
const origScale = current.k;
const unscaledOffsetX =
(current.x +
(svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) /
2) /
origScale;
const unscaledOffsetY =
(current.y +
(svgBoundingClientRect.height * origScale -
svgBoundingClientRect.height) /
2) /
origScale;
const translateX =
unscaledOffsetX * newScale -
(newScale * svgBoundingClientRect.width - svgBoundingClientRect.width) / 2;
const translateY =
unscaledOffsetY * newScale -
(newScale * svgBoundingClientRect.height - svgBoundingClientRect.height) /
2;
return [translateX, translateY];
}