mirror of
https://github.com/ansible/awx.git
synced 2026-03-19 09:57:33 -02:30
Fully functioning workflow editor without read-only view modal and without prompting.
This commit is contained in:
@@ -22,7 +22,9 @@ import Teams from './models/Teams';
|
|||||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||||
import UnifiedJobs from './models/UnifiedJobs';
|
import UnifiedJobs from './models/UnifiedJobs';
|
||||||
import Users from './models/Users';
|
import Users from './models/Users';
|
||||||
|
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||||
import WorkflowJobs from './models/WorkflowJobs';
|
import WorkflowJobs from './models/WorkflowJobs';
|
||||||
|
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||||
|
|
||||||
const AdHocCommandsAPI = new AdHocCommands();
|
const AdHocCommandsAPI = new AdHocCommands();
|
||||||
@@ -49,7 +51,9 @@ const TeamsAPI = new Teams();
|
|||||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||||
const UnifiedJobsAPI = new UnifiedJobs();
|
const UnifiedJobsAPI = new UnifiedJobs();
|
||||||
const UsersAPI = new Users();
|
const UsersAPI = new Users();
|
||||||
|
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||||
const WorkflowJobsAPI = new WorkflowJobs();
|
const WorkflowJobsAPI = new WorkflowJobs();
|
||||||
|
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -77,6 +81,8 @@ export {
|
|||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
UnifiedJobsAPI,
|
UnifiedJobsAPI,
|
||||||
UsersAPI,
|
UsersAPI,
|
||||||
|
WorkflowApprovalTemplatesAPI,
|
||||||
WorkflowJobsAPI,
|
WorkflowJobsAPI,
|
||||||
|
WorkflowJobTemplateNodesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
};
|
};
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
Normal file
10
awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
Normal 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;
|
||||||
56
awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
Normal file
56
awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
Normal 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;
|
||||||
@@ -9,6 +9,10 @@ class WorkflowJobTemplates extends Base {
|
|||||||
readNodes(id, params) {
|
readNodes(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { 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;
|
export default WorkflowJobTemplates;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { Wizard } from '@patternfly/react-core';
|
import { Wizard } from '@patternfly/react-core';
|
||||||
import SelectResourceStep from './SelectResourceStep';
|
import SelectResourceStep from './SelectResourceStep';
|
||||||
import SelectRoleStep from './SelectRoleStep';
|
import SelectRoleStep from './SelectRoleStep';
|
||||||
import SelectableCard from './SelectableCard';
|
import { SelectableCard } from '@components/SelectableCard';
|
||||||
import { TeamsAPI, UsersAPI } from '../../api';
|
import { TeamsAPI, UsersAPI } from '../../api';
|
||||||
|
|
||||||
const readUsers = async queryParams =>
|
const readUsers = async queryParams =>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { default as AddResourceRole } from './AddResourceRole';
|
export { default as AddResourceRole } from './AddResourceRole';
|
||||||
export { default as CheckboxCard } from './CheckboxCard';
|
export { default as CheckboxCard } from './CheckboxCard';
|
||||||
export { default as SelectableCard } from './SelectableCard';
|
|
||||||
export { default as SelectResourceStep } from './SelectResourceStep';
|
export { default as SelectResourceStep } from './SelectResourceStep';
|
||||||
export { default as SelectRoleStep } from './SelectRoleStep';
|
export { default as SelectRoleStep } from './SelectRoleStep';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/components/HorizontalSeparator/index.js
Normal file
1
awx/ui_next/src/components/HorizontalSeparator/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './HorizontalSeparator';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Component } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ const SelectableItem = styled.div`
|
|||||||
? 'var(--pf-global--active-color--100)'
|
? 'var(--pf-global--active-color--100)'
|
||||||
: 'var(--pf-global--BorderColor--200)'};
|
: 'var(--pf-global--BorderColor--200)'};
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
font-weight: bold;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
@@ -24,31 +23,31 @@ const Indicator = styled.div`
|
|||||||
props.isSelected ? 'var(--pf-global--active-color--100)' : null};
|
props.isSelected ? 'var(--pf-global--active-color--100)' : null};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Label = styled.div`
|
const Contents = styled.div`
|
||||||
display: flex;
|
padding: 10px 20px;
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class SelectableCard extends Component {
|
const Description = styled.p`
|
||||||
render() {
|
font-size: 14px;
|
||||||
const { label, onClick, isSelected, dataCy } = this.props;
|
`;
|
||||||
|
|
||||||
return (
|
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
||||||
<SelectableItem
|
return (
|
||||||
onClick={onClick}
|
<SelectableItem
|
||||||
onKeyPress={onClick}
|
onClick={onClick}
|
||||||
role="button"
|
onKeyPress={onClick}
|
||||||
tabIndex="0"
|
role="button"
|
||||||
data-cy={dataCy}
|
tabIndex="0"
|
||||||
isSelected={isSelected}
|
data-cy={dataCy}
|
||||||
>
|
isSelected={isSelected}
|
||||||
<Indicator isSelected={isSelected} />
|
>
|
||||||
<Label>{label}</Label>
|
<Indicator isSelected={isSelected} />
|
||||||
</SelectableItem>
|
<Contents>
|
||||||
);
|
<b>{label}</b>
|
||||||
}
|
<Description>{description}</Description>
|
||||||
|
</Contents>
|
||||||
|
</SelectableItem>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectableCard.propTypes = {
|
SelectableCard.propTypes = {
|
||||||
@@ -59,6 +58,7 @@ SelectableCard.propTypes = {
|
|||||||
|
|
||||||
SelectableCard.defaultProps = {
|
SelectableCard.defaultProps = {
|
||||||
label: '',
|
label: '',
|
||||||
|
description: '',
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
1
awx/ui_next/src/components/SelectableCard/index.js
Normal file
1
awx/ui_next/src/components/SelectableCard/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as SelectableCard } from './SelectableCard';
|
||||||
@@ -20,19 +20,26 @@ const GridDL = styled.dl`
|
|||||||
function WorkflowNodeHelp({ node, i18n }) {
|
function WorkflowNodeHelp({ node, i18n }) {
|
||||||
let nodeType;
|
let nodeType;
|
||||||
if (node.unifiedJobTemplate) {
|
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':
|
case 'job':
|
||||||
nodeType = i18n._(t`Job Template`);
|
nodeType = i18n._(t`Job Template`);
|
||||||
break;
|
break;
|
||||||
|
case 'workflow_job_template':
|
||||||
case 'workflow_job':
|
case 'workflow_job':
|
||||||
nodeType = i18n._(t`Workflow Job Template`);
|
nodeType = i18n._(t`Workflow Job Template`);
|
||||||
break;
|
break;
|
||||||
|
case 'project':
|
||||||
case 'project_update':
|
case 'project_update':
|
||||||
nodeType = i18n._(t`Project Update`);
|
nodeType = i18n._(t`Project Update`);
|
||||||
break;
|
break;
|
||||||
|
case 'inventory_source':
|
||||||
case 'inventory_update':
|
case 'inventory_update':
|
||||||
nodeType = i18n._(t`Inventory Update`);
|
nodeType = i18n._(t`Inventory Update`);
|
||||||
break;
|
break;
|
||||||
|
case 'workflow_approval_template':
|
||||||
case 'workflow_approval':
|
case 'workflow_approval':
|
||||||
nodeType = i18n._(t`Workflow Approval`);
|
nodeType = i18n._(t`Workflow Approval`);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -13,43 +13,30 @@ const NodeTypeLetter = styled.foreignObject`
|
|||||||
|
|
||||||
function WorkflowNodeTypeLetter({ node }) {
|
function WorkflowNodeTypeLetter({ node }) {
|
||||||
let nodeTypeLetter;
|
let nodeTypeLetter;
|
||||||
if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) {
|
if (
|
||||||
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 (
|
|
||||||
node.unifiedJobTemplate &&
|
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':
|
case 'job':
|
||||||
nodeTypeLetter = 'JT';
|
nodeTypeLetter = 'JT';
|
||||||
break;
|
break;
|
||||||
|
case 'project':
|
||||||
case 'project_update':
|
case 'project_update':
|
||||||
nodeTypeLetter = 'P';
|
nodeTypeLetter = 'P';
|
||||||
break;
|
break;
|
||||||
|
case 'inventory_source':
|
||||||
case 'inventory_update':
|
case 'inventory_update':
|
||||||
nodeTypeLetter = 'I';
|
nodeTypeLetter = 'I';
|
||||||
break;
|
break;
|
||||||
|
case 'workflow_job_template':
|
||||||
case 'workflow_job':
|
case 'workflow_job':
|
||||||
nodeTypeLetter = 'W';
|
nodeTypeLetter = 'W';
|
||||||
break;
|
break;
|
||||||
|
case 'workflow_approval_template':
|
||||||
case 'workflow_approval':
|
case 'workflow_approval':
|
||||||
nodeTypeLetter = <PauseIcon />;
|
nodeTypeLetter = <PauseIcon />;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -8,7 +8,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
|
|||||||
return (
|
return (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
variant="danger"
|
variant="danger"
|
||||||
title="Remove Node"
|
title={i18n._(t`Remove Node`)}
|
||||||
isOpen={nodeToDelete}
|
isOpen={nodeToDelete}
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
actions={[
|
actions={[
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
import React, { Fragment, useState, useEffect } from 'react';
|
import React, { Fragment, useState, useEffect } from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
|
||||||
import { layoutGraph } from '@util/workflow';
|
import { layoutGraph } from '@util/workflow';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
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 NodeDeleteModal from './Modals/NodeDeleteModal';
|
||||||
import VisualizerGraph from './VisualizerGraph';
|
import VisualizerGraph from './VisualizerGraph';
|
||||||
import VisualizerStartScreen from './VisualizerStartScreen';
|
import VisualizerStartScreen from './VisualizerStartScreen';
|
||||||
import VisualizerToolbar from './VisualizerToolbar';
|
import VisualizerToolbar from './VisualizerToolbar';
|
||||||
import { WorkflowJobTemplatesAPI } from '@api';
|
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
|
||||||
|
import {
|
||||||
|
WorkflowApprovalTemplatesAPI,
|
||||||
|
WorkflowJobTemplatesAPI,
|
||||||
|
WorkflowJobTemplateNodesAPI,
|
||||||
|
} from '@api';
|
||||||
|
|
||||||
const CenteredContent = styled.div`
|
const CenteredContent = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -25,7 +36,11 @@ const Wrapper = styled.div`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
|
const fetchWorkflowNodes = async (
|
||||||
|
templateId,
|
||||||
|
pageNo = 1,
|
||||||
|
workflowNodes = []
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
|
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
|
||||||
page_size: 200,
|
page_size: 200,
|
||||||
@@ -35,36 +50,111 @@ const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
|
|||||||
return await fetchWorkflowNodes(
|
return await fetchWorkflowNodes(
|
||||||
templateId,
|
templateId,
|
||||||
pageNo + 1,
|
pageNo + 1,
|
||||||
nodes.concat(data.results)
|
workflowNodes.concat(data.results)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return nodes.concat(data.results);
|
return workflowNodes.concat(data.results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function Visualizer({ template, i18n }) {
|
function Visualizer({ history, template, i18n }) {
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [graphLinks, setGraphLinks] = useState([]);
|
const [links, setLinks] = useState([]);
|
||||||
// We'll also need to store the original set of nodes...
|
const [nodes, setNodes] = useState([]);
|
||||||
const [graphNodes, setGraphNodes] = useState([]);
|
const [linkToDelete, setLinkToDelete] = useState(null);
|
||||||
|
const [linkToEdit, setLinkToEdit] = useState(null);
|
||||||
const [nodePositions, setNodePositions] = useState(null);
|
const [nodePositions, setNodePositions] = useState(null);
|
||||||
const [nodeToDelete, setNodeToDelete] = 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 deleteNode = () => {
|
||||||
const nodeId = nodeToDelete.id;
|
const nodeId = nodeToDelete.id;
|
||||||
const newGraphNodes = [...graphNodes];
|
const newNodes = [...nodes];
|
||||||
const newGraphLinks = [...graphLinks];
|
const newLinks = [...links];
|
||||||
|
|
||||||
// Remove the node from the array
|
newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
|
||||||
for (let i = newGraphNodes.length; i--; ) {
|
|
||||||
if (newGraphNodes[i].id === nodeId) {
|
|
||||||
newGraphNodes.splice(i, 1);
|
|
||||||
i = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the links
|
// Update the links
|
||||||
const parents = [];
|
const parents = [];
|
||||||
@@ -72,8 +162,8 @@ function Visualizer({ template, i18n }) {
|
|||||||
const linkParentMapping = {};
|
const linkParentMapping = {};
|
||||||
|
|
||||||
// Remove any links that reference this node
|
// Remove any links that reference this node
|
||||||
for (let i = newGraphLinks.length; i--; ) {
|
for (let i = newLinks.length; i--; ) {
|
||||||
const link = newGraphLinks[i];
|
const link = newLinks[i];
|
||||||
|
|
||||||
if (!linkParentMapping[link.target.id]) {
|
if (!linkParentMapping[link.target.id]) {
|
||||||
linkParentMapping[link.target.id] = [];
|
linkParentMapping[link.target.id] = [];
|
||||||
@@ -87,7 +177,7 @@ function Visualizer({ template, i18n }) {
|
|||||||
} else if (link.target.id === nodeId) {
|
} else if (link.target.id === nodeId) {
|
||||||
parents.push(link.source.id);
|
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
|
// We only want to create a link from the start node to this node if it
|
||||||
// doesn't have any other parents
|
// doesn't have any other parents
|
||||||
if (linkParentMapping[child.id].length === 1) {
|
if (linkParentMapping[child.id].length === 1) {
|
||||||
newGraphLinks.push({
|
newLinks.push({
|
||||||
source: { id: parentId },
|
source: { id: parentId },
|
||||||
target: { id: child.id },
|
target: { id: child.id },
|
||||||
edgeType: 'always',
|
edgeType: 'always',
|
||||||
@@ -106,7 +196,7 @@ function Visualizer({ template, i18n }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (!linkParentMapping[child.id].includes(parentId)) {
|
} else if (!linkParentMapping[child.id].includes(parentId)) {
|
||||||
newGraphLinks.push({
|
newLinks.push({
|
||||||
source: { id: parentId },
|
source: { id: parentId },
|
||||||
target: { id: child.id },
|
target: { id: child.id },
|
||||||
edgeType: child.edgeType,
|
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
|
// need to track that this node has been deleted if it's not new
|
||||||
|
|
||||||
|
if (!unsavedChanges) {
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
}
|
||||||
setNodeToDelete(null);
|
setNodeToDelete(null);
|
||||||
setGraphNodes(newGraphNodes);
|
setNodes(newNodes);
|
||||||
setGraphLinks(newGraphLinks);
|
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(() => {
|
useEffect(() => {
|
||||||
const buildGraphArrays = nodes => {
|
const buildGraphArrays = workflowNodes => {
|
||||||
const nonRootNodeIds = [];
|
const nonRootNodeIds = [];
|
||||||
const allNodeIds = [];
|
const allNodeIds = [];
|
||||||
const arrayOfLinksForChart = [];
|
const arrayOfLinksForChart = [];
|
||||||
const nodeIdToChartNodeIdMapping = {};
|
const nodeIdToChartNodeIdMapping = {};
|
||||||
const chartNodeIdToIndexMapping = {};
|
const chartNodeIdToIndexMapping = {};
|
||||||
const nodeRef = {};
|
|
||||||
let nodeIdCounter = 1;
|
|
||||||
const arrayOfNodesForChart = [
|
const arrayOfNodesForChart = [
|
||||||
{
|
{
|
||||||
id: nodeIdCounter,
|
id: 1,
|
||||||
unifiedJobTemplate: {
|
unifiedJobTemplate: {
|
||||||
name: i18n._(t`START`),
|
name: i18n._(t`START`),
|
||||||
},
|
},
|
||||||
type: 'node',
|
type: 'node',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
nodeIdCounter++;
|
let nodeIdCounter = 2;
|
||||||
// Assign each node an ID - 0 is reserved for the start node. We need to
|
// 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
|
// make sure that we have an ID on every node including new nodes so the
|
||||||
// ID returned by the api won't do
|
// ID returned by the api won't do
|
||||||
nodes.forEach(node => {
|
workflowNodes.forEach(node => {
|
||||||
node.workflowMakerNodeId = nodeIdCounter;
|
node.workflowMakerNodeId = nodeIdCounter;
|
||||||
nodeRef[nodeIdCounter] = {
|
|
||||||
originalNodeObject: node,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeObj = {
|
const nodeObj = {
|
||||||
index: nodeIdCounter - 1,
|
|
||||||
id: nodeIdCounter,
|
id: nodeIdCounter,
|
||||||
type: 'node',
|
type: 'node',
|
||||||
|
originalNodeObject: node,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.summary_fields.job) {
|
if (node.summary_fields.job) {
|
||||||
nodeObj.job = node.summary_fields.job;
|
nodeObj.job = node.summary_fields.job;
|
||||||
}
|
}
|
||||||
if (node.summary_fields.unified_job_template) {
|
if (node.summary_fields.unified_job_template) {
|
||||||
nodeRef[nodeIdCounter].unifiedJobTemplate =
|
|
||||||
node.summary_fields.unified_job_template;
|
|
||||||
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
|
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +694,7 @@ function Visualizer({ template, i18n }) {
|
|||||||
nodeIdCounter++;
|
nodeIdCounter++;
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach(node => {
|
workflowNodes.forEach(node => {
|
||||||
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
|
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
|
||||||
node.success_nodes.forEach(nodeId => {
|
node.success_nodes.forEach(nodeId => {
|
||||||
const targetIndex =
|
const targetIndex =
|
||||||
@@ -226,14 +748,15 @@ function Visualizer({ template, i18n }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setGraphNodes(arrayOfNodesForChart);
|
setNodes(arrayOfNodesForChart);
|
||||||
setGraphLinks(arrayOfLinksForChart);
|
setLinks(arrayOfLinksForChart);
|
||||||
|
setNextNodeId(nodeIdCounter);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const nodes = await fetchWorkflowNodes(template.id);
|
const workflowNodes = await fetchWorkflowNodes(template.id);
|
||||||
buildGraphArrays(nodes);
|
buildGraphArrays(workflowNodes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setContentError(error);
|
setContentError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,9 +768,10 @@ function Visualizer({ template, i18n }) {
|
|||||||
|
|
||||||
// Update positions of nodes/links
|
// Update positions of nodes/links
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (graphNodes) {
|
if (nodes) {
|
||||||
const newNodePositions = {};
|
const newNodePositions = {};
|
||||||
const g = layoutGraph(graphNodes, graphLinks);
|
const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
|
||||||
|
const g = layoutGraph(nonDeletedNodes, links);
|
||||||
|
|
||||||
g.nodes().forEach(node => {
|
g.nodes().forEach(node => {
|
||||||
newNodePositions[node] = g.node(node);
|
newNodePositions[node] = g.node(node);
|
||||||
@@ -255,7 +779,7 @@ function Visualizer({ template, i18n }) {
|
|||||||
|
|
||||||
setNodePositions(newNodePositions);
|
setNodePositions(newNodePositions);
|
||||||
}
|
}
|
||||||
}, [graphLinks, graphNodes]);
|
}, [links, nodes]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -276,17 +800,38 @@ function Visualizer({ template, i18n }) {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<VisualizerToolbar template={template} />
|
<VisualizerToolbar
|
||||||
{graphLinks.length > 0 ? (
|
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
|
<VisualizerGraph
|
||||||
links={graphLinks}
|
links={links}
|
||||||
nodes={graphNodes}
|
nodes={nodes}
|
||||||
nodePositions={nodePositions}
|
nodePositions={nodePositions}
|
||||||
readOnly={!template.summary_fields.user_capabilities.edit}
|
readOnly={!template.summary_fields.user_capabilities.edit}
|
||||||
|
onAddNodeClick={startAddNode}
|
||||||
|
onEditNodeClick={startEditNode}
|
||||||
onDeleteNodeClick={setNodeToDelete}
|
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>
|
</Wrapper>
|
||||||
<NodeDeleteModal
|
<NodeDeleteModal
|
||||||
@@ -294,8 +839,74 @@ function Visualizer({ template, i18n }) {
|
|||||||
onConfirm={deleteNode}
|
onConfirm={deleteNode}
|
||||||
onCancel={() => setNodeToDelete(null)}
|
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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(Visualizer);
|
export default withI18n()(withRouter(Visualizer));
|
||||||
|
|||||||
@@ -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 * as d3 from 'd3';
|
||||||
import { calcZoomAndFit } from '@util/workflow';
|
import {
|
||||||
|
calcZoomAndFit,
|
||||||
|
constants as wfConstants,
|
||||||
|
getZoomTranslate,
|
||||||
|
} from '@util/workflow';
|
||||||
import {
|
import {
|
||||||
WorkflowHelp,
|
WorkflowHelp,
|
||||||
WorkflowLinkHelp,
|
WorkflowLinkHelp,
|
||||||
@@ -10,21 +17,103 @@ import {
|
|||||||
VisualizerLink,
|
VisualizerLink,
|
||||||
VisualizerNode,
|
VisualizerNode,
|
||||||
VisualizerStartNode,
|
VisualizerStartNode,
|
||||||
|
VisualizerKey,
|
||||||
|
VisualizerTools,
|
||||||
} from '@screens/Template/WorkflowJobTemplateVisualizer';
|
} 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,
|
links,
|
||||||
nodes,
|
nodes,
|
||||||
readOnly,
|
readOnly,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
onDeleteNodeClick,
|
onDeleteNodeClick,
|
||||||
|
onAddNodeClick,
|
||||||
|
onEditNodeClick,
|
||||||
|
onLinkEditClick,
|
||||||
|
onDeleteLinkClick,
|
||||||
|
onStartAddLinkClick,
|
||||||
|
onConfirmAddLinkClick,
|
||||||
|
onCancelAddLinkClick,
|
||||||
|
addingLink,
|
||||||
|
addLinkSourceNode,
|
||||||
|
showKey,
|
||||||
|
showTools,
|
||||||
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const [helpText, setHelpText] = useState(null);
|
const [helpText, setHelpText] = useState(null);
|
||||||
const [nodeHelp, setNodeHelp] = useState();
|
const [nodeHelp, setNodeHelp] = useState();
|
||||||
const [linkHelp, setLinkHelp] = useState();
|
const [linkHelp, setLinkHelp] = useState();
|
||||||
|
const [zoomPercentage, setZoomPercentage] = useState(100);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const gRef = 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
|
// This is the zoom function called by using the mousewheel/click and drag
|
||||||
const zoom = () => {
|
const zoom = () => {
|
||||||
const translation = [d3.event.transform.x, d3.event.transform.y];
|
const translation = [d3.event.transform.x, d3.event.transform.y];
|
||||||
@@ -32,6 +121,74 @@ function VizualizerGraph({
|
|||||||
'transform',
|
'transform',
|
||||||
`translate(${translation}) scale(${d3.event.transform.k})`
|
`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
|
const zoomRef = d3
|
||||||
@@ -46,12 +203,17 @@ function VizualizerGraph({
|
|||||||
|
|
||||||
// Attempt to zoom the graph to fit the available screen space
|
// Attempt to zoom the graph to fit the available screen space
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current);
|
const [scaleToFit, yTranslate] = calcZoomAndFit(
|
||||||
|
gRef.current,
|
||||||
|
svgRef.current
|
||||||
|
);
|
||||||
|
|
||||||
d3.select(svgRef.current).call(
|
d3.select(svgRef.current).call(
|
||||||
zoomRef.transform,
|
zoomRef.transform,
|
||||||
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setZoomPercentage(scaleToFit * 100);
|
||||||
// We only want this to run once (when the component mounts)
|
// We only want this to run once (when the component mounts)
|
||||||
// Including zoomRef.transform in the deps array will cause this to
|
// Including zoomRef.transform in the deps array will cause this to
|
||||||
// run very frequently.
|
// run very frequently.
|
||||||
@@ -61,7 +223,7 @@ function VizualizerGraph({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<>
|
||||||
{(helpText || nodeHelp || linkHelp) && (
|
{(helpText || nodeHelp || linkHelp) && (
|
||||||
<WorkflowHelp>
|
<WorkflowHelp>
|
||||||
{helpText && <p>{helpText}</p>}
|
{helpText && <p>{helpText}</p>}
|
||||||
@@ -69,11 +231,38 @@ function VizualizerGraph({
|
|||||||
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
|
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
|
||||||
</WorkflowHelp>
|
</WorkflowHelp>
|
||||||
)}
|
)}
|
||||||
<svg
|
<WorkflowSVG id="workflow-svg" ref={svgRef} css="">
|
||||||
id="workflow-svg"
|
<defs>
|
||||||
ref={svgRef}
|
<marker
|
||||||
css="display: flex; height: 100%; background-color: #f6f6f6;"
|
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}>
|
<g id="workflow-g" ref={gRef}>
|
||||||
{nodePositions && [
|
{nodePositions && [
|
||||||
<VisualizerStartNode
|
<VisualizerStartNode
|
||||||
@@ -81,19 +270,33 @@ function VizualizerGraph({
|
|||||||
nodePositions={nodePositions}
|
nodePositions={nodePositions}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
updateHelpText={setHelpText}
|
updateHelpText={setHelpText}
|
||||||
|
addingLink={addingLink}
|
||||||
|
onAddNodeClick={onAddNodeClick}
|
||||||
/>,
|
/>,
|
||||||
links.map(link => (
|
links.map(link => {
|
||||||
<VisualizerLink
|
if (
|
||||||
key={`link-${link.source.id}-${link.target.id}`}
|
nodePositions[link.source.id] &&
|
||||||
link={link}
|
nodePositions[link.target.id]
|
||||||
nodePositions={nodePositions}
|
) {
|
||||||
updateHelpText={setHelpText}
|
return (
|
||||||
updateLinkHelp={setLinkHelp}
|
<VisualizerLink
|
||||||
readOnly={readOnly}
|
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 => {
|
nodes.map(node => {
|
||||||
if (node.id > 1) {
|
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
|
||||||
return (
|
return (
|
||||||
<VisualizerNode
|
<VisualizerNode
|
||||||
key={`node-${node.id}`}
|
key={`node-${node.id}`}
|
||||||
@@ -102,17 +305,49 @@ function VizualizerGraph({
|
|||||||
updateHelpText={setHelpText}
|
updateHelpText={setHelpText}
|
||||||
updateNodeHelp={setNodeHelp}
|
updateNodeHelp={setNodeHelp}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
onAddNodeClick={onAddNodeClick}
|
||||||
|
onEditNodeClick={onEditNodeClick}
|
||||||
onDeleteNodeClick={onDeleteNodeClick}
|
onDeleteNodeClick={onDeleteNodeClick}
|
||||||
|
onStartAddLinkClick={onStartAddLinkClick}
|
||||||
|
onConfirmAddLinkClick={onConfirmAddLinkClick}
|
||||||
|
addingLink={addingLink}
|
||||||
|
isAddLinkSourceNode={
|
||||||
|
addLinkSourceNode && addLinkSourceNode.id === node.id
|
||||||
|
}
|
||||||
|
{...(addingLink && {
|
||||||
|
onMouseOver: () => drawPotentialLinkToNode(node),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
|
{addingLink && (
|
||||||
|
<PotentialLink
|
||||||
|
id="workflow-potentialLink"
|
||||||
|
strokeDasharray="5,5"
|
||||||
|
strokeWidth="2"
|
||||||
|
stroke="#93969A"
|
||||||
|
markerEnd="url(#workflow-triangle)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</WorkflowSVG>
|
||||||
</Fragment>
|
<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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
|
||||||
@@ -12,6 +13,10 @@ import {
|
|||||||
WorkflowActionTooltipItem,
|
WorkflowActionTooltipItem,
|
||||||
} from '@components/Workflow';
|
} from '@components/Workflow';
|
||||||
|
|
||||||
|
const LinkG = styled.g`
|
||||||
|
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
|
||||||
|
`;
|
||||||
|
|
||||||
function VisualizerLink({
|
function VisualizerLink({
|
||||||
link,
|
link,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
@@ -19,6 +24,10 @@ function VisualizerLink({
|
|||||||
updateHelpText,
|
updateHelpText,
|
||||||
updateLinkHelp,
|
updateLinkHelp,
|
||||||
i18n,
|
i18n,
|
||||||
|
onLinkEditClick,
|
||||||
|
onDeleteLinkClick,
|
||||||
|
addingLink,
|
||||||
|
onAddNodeClick,
|
||||||
}) {
|
}) {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
const [pathD, setPathD] = useState();
|
const [pathD, setPathD] = useState();
|
||||||
@@ -30,6 +39,11 @@ function VisualizerLink({
|
|||||||
<WorkflowActionTooltipItem
|
<WorkflowActionTooltipItem
|
||||||
id="link-add-node"
|
id="link-add-node"
|
||||||
key="add"
|
key="add"
|
||||||
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onAddNodeClick(link.source.id, link.target.id);
|
||||||
|
}}
|
||||||
onMouseEnter={() =>
|
onMouseEnter={() =>
|
||||||
updateHelpText(i18n._(t`Add a new node between these two nodes`))
|
updateHelpText(i18n._(t`Add a new node between these two nodes`))
|
||||||
}
|
}
|
||||||
@@ -49,6 +63,7 @@ function VisualizerLink({
|
|||||||
key="edit"
|
key="edit"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => onLinkEditClick(link)}
|
||||||
>
|
>
|
||||||
<PencilAltIcon />
|
<PencilAltIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -57,6 +72,7 @@ function VisualizerLink({
|
|||||||
key="delete"
|
key="delete"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => onDeleteLinkClick(link)}
|
||||||
>
|
>
|
||||||
<TrashAltIcon />
|
<TrashAltIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -98,11 +114,12 @@ function VisualizerLink({
|
|||||||
}, [link, nodePositions]);
|
}, [link, nodePositions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<LinkG
|
||||||
className="WorkflowGraph-link"
|
className="WorkflowGraph-link"
|
||||||
id={`link-${link.source.id}-${link.target.id}`}
|
id={`link-${link.source.id}-${link.target.id}`}
|
||||||
onMouseEnter={handleLinkMouseEnter}
|
onMouseEnter={handleLinkMouseEnter}
|
||||||
onMouseLeave={handleLinkMouseLeave}
|
onMouseLeave={handleLinkMouseLeave}
|
||||||
|
ignorePointerEvents={addingLink}
|
||||||
>
|
>
|
||||||
<polygon
|
<polygon
|
||||||
id={`link-${link.source.id}-${link.target.id}-overlay`}
|
id={`link-${link.source.id}-${link.target.id}-overlay`}
|
||||||
@@ -129,7 +146,7 @@ function VisualizerLink({
|
|||||||
actions={tooltipActions}
|
actions={tooltipActions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</g>
|
</LinkG>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ import {
|
|||||||
WorkflowNodeTypeLetter,
|
WorkflowNodeTypeLetter,
|
||||||
} from '@components/Workflow';
|
} from '@components/Workflow';
|
||||||
|
|
||||||
// dont need this in this component
|
|
||||||
const NodeG = styled.g`
|
const NodeG = styled.g`
|
||||||
|
pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
|
||||||
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeContents = styled.foreignObject`
|
const NodeContents = styled.foreignObject`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0px 10px;
|
padding: 0px 10px;
|
||||||
|
background-color: ${props =>
|
||||||
|
props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeDefaultLabel = styled.p`
|
const NodeDefaultLabel = styled.p`
|
||||||
@@ -42,6 +44,13 @@ function VisualizerNode({
|
|||||||
readOnly,
|
readOnly,
|
||||||
i18n,
|
i18n,
|
||||||
onDeleteNodeClick,
|
onDeleteNodeClick,
|
||||||
|
onStartAddLinkClick,
|
||||||
|
onConfirmAddLinkClick,
|
||||||
|
addingLink,
|
||||||
|
onMouseOver,
|
||||||
|
isAddLinkSourceNode,
|
||||||
|
onAddNodeClick,
|
||||||
|
onEditNodeClick,
|
||||||
}) {
|
}) {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
|
|
||||||
@@ -49,6 +58,29 @@ function VisualizerNode({
|
|||||||
const nodeEl = document.getElementById(`node-${node.id}`);
|
const nodeEl = document.getElementById(`node-${node.id}`);
|
||||||
nodeEl.parentNode.appendChild(nodeEl);
|
nodeEl.parentNode.appendChild(nodeEl);
|
||||||
setHovering(true);
|
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 = (
|
const viewDetailsAction = (
|
||||||
@@ -70,6 +102,11 @@ function VisualizerNode({
|
|||||||
key="add"
|
key="add"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onAddNodeClick(node.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -79,6 +116,11 @@ function VisualizerNode({
|
|||||||
key="edit"
|
key="edit"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onEditNodeClick(node);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PencilAltIcon />
|
<PencilAltIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -89,6 +131,11 @@ function VisualizerNode({
|
|||||||
updateHelpText(i18n._(t`Link to an available node`))
|
updateHelpText(i18n._(t`Link to an available node`))
|
||||||
}
|
}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onStartAddLinkClick(node);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -97,7 +144,11 @@ function VisualizerNode({
|
|||||||
key="delete"
|
key="delete"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
onClick={() => onDeleteNodeClick(node)}
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onDeleteNodeClick(node);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TrashAltIcon />
|
<TrashAltIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
@@ -109,23 +160,32 @@ function VisualizerNode({
|
|||||||
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
||||||
.y - nodePositions[1].y})`}
|
.y - nodePositions[1].y})`}
|
||||||
job={node.job}
|
job={node.job}
|
||||||
|
noPointerEvents={isAddLinkSourceNode}
|
||||||
onMouseEnter={handleNodeMouseEnter}
|
onMouseEnter={handleNodeMouseEnter}
|
||||||
onMouseLeave={() => setHovering(false)}
|
onMouseLeave={handleNodeMouseLeave}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
width={wfConstants.nodeW}
|
width={wfConstants.nodeW}
|
||||||
height={wfConstants.nodeH}
|
height={wfConstants.nodeH}
|
||||||
rx="2"
|
rx="2"
|
||||||
ry="2"
|
ry="2"
|
||||||
stroke="#93969A"
|
stroke={
|
||||||
strokeWidth="2px"
|
hovering && addingLink && !node.isInvalidLinkTarget
|
||||||
|
? '#007ABC'
|
||||||
|
: '#93969A'
|
||||||
|
}
|
||||||
|
strokeWidth="4px"
|
||||||
fill="#FFFFFF"
|
fill="#FFFFFF"
|
||||||
/>
|
/>
|
||||||
<NodeContents
|
<NodeContents
|
||||||
height="60"
|
height="60"
|
||||||
width="180"
|
width="180"
|
||||||
onMouseEnter={() => updateNodeHelp(node)}
|
isInvalidLinkTarget={node.isInvalidLinkTarget}
|
||||||
onMouseLeave={() => updateNodeHelp(null)}
|
{...(!addingLink && {
|
||||||
|
onMouseEnter: () => updateNodeHelp(node),
|
||||||
|
onMouseLeave: () => updateNodeHelp(null),
|
||||||
|
})}
|
||||||
|
onClick={() => handleNodeClick()}
|
||||||
>
|
>
|
||||||
<NodeDefaultLabel>
|
<NodeDefaultLabel>
|
||||||
{node.unifiedJobTemplate
|
{node.unifiedJobTemplate
|
||||||
@@ -134,7 +194,7 @@ function VisualizerNode({
|
|||||||
</NodeDefaultLabel>
|
</NodeDefaultLabel>
|
||||||
</NodeContents>
|
</NodeContents>
|
||||||
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
|
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
|
||||||
{hovering && (
|
{hovering && !addingLink && (
|
||||||
<WorkflowActionTooltip
|
<WorkflowActionTooltip
|
||||||
pointX={wfConstants.nodeW}
|
pointX={wfConstants.nodeW}
|
||||||
pointY={wfConstants.nodeH / 2}
|
pointY={wfConstants.nodeH / 2}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { PlusIcon } from '@patternfly/react-icons';
|
||||||
import { constants as wfConstants } from '@util/workflow';
|
import { constants as wfConstants } from '@util/workflow';
|
||||||
import {
|
import {
|
||||||
WorkflowActionTooltip,
|
WorkflowActionTooltip,
|
||||||
WorkflowActionTooltipItem,
|
WorkflowActionTooltipItem,
|
||||||
} from '@components/Workflow';
|
} from '@components/Workflow';
|
||||||
|
|
||||||
|
const StartG = styled.g`
|
||||||
|
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
|
||||||
|
`;
|
||||||
|
|
||||||
function VisualizerStartNode({
|
function VisualizerStartNode({
|
||||||
updateHelpText,
|
updateHelpText,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
readOnly,
|
readOnly,
|
||||||
i18n,
|
i18n,
|
||||||
|
addingLink,
|
||||||
|
onAddNodeClick,
|
||||||
}) {
|
}) {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
|
|
||||||
@@ -22,11 +30,12 @@ function VisualizerStartNode({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<StartG
|
||||||
id="node-1"
|
id="node-1"
|
||||||
transform={`translate(${nodePositions[1].x},0)`}
|
transform={`translate(${nodePositions[1].x},0)`}
|
||||||
onMouseEnter={handleNodeMouseEnter}
|
onMouseEnter={handleNodeMouseEnter}
|
||||||
onMouseLeave={() => setHovering(false)}
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
ignorePointerEvents={addingLink}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
width={wfConstants.rootW}
|
width={wfConstants.rootW}
|
||||||
@@ -50,13 +59,18 @@ function VisualizerStartNode({
|
|||||||
key="add"
|
key="add"
|
||||||
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||||
onMouseLeave={() => updateHelpText(null)}
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => {
|
||||||
|
updateHelpText(null);
|
||||||
|
setHovering(false);
|
||||||
|
onAddNodeClick(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="pf-icon pf-icon-add-circle-o" />
|
<PlusIcon />
|
||||||
</WorkflowActionTooltipItem>,
|
</WorkflowActionTooltipItem>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</g>
|
</StartG>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const StartPanel = styled.div`
|
|||||||
padding: 60px 80px;
|
padding: 60px 80px;
|
||||||
border: 1px solid #c7c7c7;
|
border: 1px solid #c7c7c7;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: var(--pf-global--Color--200);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -29,13 +28,17 @@ const StartPanelWrapper = styled.div`
|
|||||||
background-color: #f6f6f6;
|
background-color: #f6f6f6;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function StartScreen({ i18n }) {
|
function StartScreen({ i18n, onStartClick }) {
|
||||||
return (
|
return (
|
||||||
<div css="flex: 1">
|
<div css="flex: 1">
|
||||||
<StartPanelWrapper>
|
<StartPanelWrapper>
|
||||||
<StartPanel>
|
<StartPanel>
|
||||||
<p>{i18n._(t`Please click the Start button to begin.`)}</p>
|
<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`)}
|
{i18n._(t`Start`)}
|
||||||
</Button>
|
</Button>
|
||||||
</StartPanel>
|
</StartPanel>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Badge as PFBadge, Button } from '@patternfly/react-core';
|
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
|
||||||
import {
|
import {
|
||||||
BookIcon,
|
BookIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
@@ -22,10 +21,34 @@ const Badge = styled(PFBadge)`
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function Toolbar({ history, i18n, template }) {
|
const ActionButton = styled(Button)`
|
||||||
const handleVisualizerCancel = () => {
|
padding: 6px 10px;
|
||||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -52,35 +75,54 @@ function Toolbar({ history, i18n, template }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>{i18n._(t`Total Nodes`)}</div>
|
<div>{i18n._(t`Total Nodes`)}</div>
|
||||||
<Badge isRead>0</Badge>
|
<Badge isRead>{totalNodes}</Badge>
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<Button variant="plain">
|
<Tooltip content={i18n._(t`Toggle Key`)} position="bottom">
|
||||||
<CompassIcon />
|
<ActionButton
|
||||||
</Button>
|
variant="plain"
|
||||||
<Button variant="plain">
|
onClick={onKeyToggle}
|
||||||
<WrenchIcon />
|
isActive={keyShown}
|
||||||
</Button>
|
>
|
||||||
<Button variant="plain">
|
<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 />
|
<DownloadIcon />
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button variant="plain">
|
<ActionButton variant="plain" isDisabled>
|
||||||
<BookIcon />
|
<BookIcon />
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button variant="plain">
|
<ActionButton variant="plain" isDisabled>
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button variant="plain">
|
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom">
|
||||||
<TrashAltIcon />
|
<ActionButton
|
||||||
</Button>
|
variant="plain"
|
||||||
|
isDisabled={totalNodes === 0}
|
||||||
|
aria-label={i18n._(t`Delete all nodes`)}
|
||||||
|
onClick={onDeleteAllClick}
|
||||||
|
>
|
||||||
|
<TrashAltIcon />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<Button variant="primary" onClick={handleVisualizerCancel}>
|
<Button variant="primary" onClick={onSave}>
|
||||||
{i18n._(t`Save`)}
|
{i18n._(t`Save`)}
|
||||||
</Button>
|
</Button>
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
aria-label={i18n._(t`Close`)}
|
aria-label={i18n._(t`Close`)}
|
||||||
onClick={handleVisualizerCancel}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<TimesIcon />
|
<TimesIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -90,4 +132,4 @@ function Toolbar({ history, i18n, template }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(withRouter(Toolbar));
|
export default withI18n()(Toolbar);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -5,3 +5,5 @@ export { default as VisualizerStartScreen } from './VisualizerStartScreen';
|
|||||||
export { default as VisualizerStartNode } from './VisualizerStartNode';
|
export { default as VisualizerStartNode } from './VisualizerStartNode';
|
||||||
export { default as VisualizerLink } from './VisualizerLink';
|
export { default as VisualizerLink } from './VisualizerLink';
|
||||||
export { default as VisualizerNode } from './VisualizerNode';
|
export { default as VisualizerNode } from './VisualizerNode';
|
||||||
|
export { default as VisualizerKey } from './VisualizerKey';
|
||||||
|
export { default as VisualizerTools } from './VisualizerTools';
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ export const constants = {
|
|||||||
rootH: 40,
|
rootH: 40,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function calcZoomAndFit(gRef) {
|
export function calcZoomAndFit(gRef, svgRef) {
|
||||||
|
const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node());
|
||||||
const gBoundingClientRect = d3
|
const gBoundingClientRect = d3
|
||||||
.select(gRef)
|
.select(gRef)
|
||||||
.node()
|
.node()
|
||||||
.getBoundingClientRect();
|
.getBoundingClientRect();
|
||||||
|
|
||||||
|
gBoundingClientRect.height = gBoundingClientRect.height / currentScale;
|
||||||
|
gBoundingClientRect.width = gBoundingClientRect.width / currentScale;
|
||||||
|
|
||||||
const gBBoxDimensions = d3
|
const gBBoxDimensions = d3
|
||||||
.select(gRef)
|
.select(gRef)
|
||||||
.node()
|
.node()
|
||||||
@@ -45,7 +49,7 @@ export function calcZoomAndFit(gRef) {
|
|||||||
yTranslate =
|
yTranslate =
|
||||||
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
|
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
|
||||||
2 -
|
2 -
|
||||||
gBBoxDimensions.y * scaleToFit;
|
(gBBoxDimensions.y / currentScale) * scaleToFit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [scaleToFit, yTranslate];
|
return [scaleToFit, yTranslate];
|
||||||
@@ -172,3 +176,29 @@ export function layoutGraph(nodes, links) {
|
|||||||
|
|
||||||
return g;
|
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];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user