mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 19:10:07 -03:30
Fully functioning workflow editor without read-only view modal and without prompting.
This commit is contained in:
parent
ca478ac880
commit
de55af6ae6
@ -22,7 +22,9 @@ import Teams from './models/Teams';
|
||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||
import UnifiedJobs from './models/UnifiedJobs';
|
||||
import Users from './models/Users';
|
||||
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
|
||||
import WorkflowJobs from './models/WorkflowJobs';
|
||||
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||
|
||||
const AdHocCommandsAPI = new AdHocCommands();
|
||||
@ -49,7 +51,9 @@ const TeamsAPI = new Teams();
|
||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||
const UnifiedJobsAPI = new UnifiedJobs();
|
||||
const UsersAPI = new Users();
|
||||
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
|
||||
const WorkflowJobsAPI = new WorkflowJobs();
|
||||
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
|
||||
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
||||
|
||||
export {
|
||||
@ -77,6 +81,8 @@ export {
|
||||
UnifiedJobTemplatesAPI,
|
||||
UnifiedJobsAPI,
|
||||
UsersAPI,
|
||||
WorkflowApprovalTemplatesAPI,
|
||||
WorkflowJobsAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
};
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
||||
import { Wizard } from '@patternfly/react-core';
|
||||
import SelectResourceStep from './SelectResourceStep';
|
||||
import SelectRoleStep from './SelectRoleStep';
|
||||
import SelectableCard from './SelectableCard';
|
||||
import { SelectableCard } from '@components/SelectableCard';
|
||||
import { TeamsAPI, UsersAPI } from '../../api';
|
||||
|
||||
const readUsers = async queryParams =>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export { default as AddResourceRole } from './AddResourceRole';
|
||||
export { default as CheckboxCard } from './CheckboxCard';
|
||||
export { default as SelectableCard } from './SelectableCard';
|
||||
export { default as SelectResourceStep } from './SelectResourceStep';
|
||||
export { default as SelectRoleStep } from './SelectRoleStep';
|
||||
|
||||
@ -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 styled from 'styled-components';
|
||||
|
||||
@ -12,7 +12,6 @@ const SelectableItem = styled.div`
|
||||
? 'var(--pf-global--active-color--100)'
|
||||
: 'var(--pf-global--BorderColor--200)'};
|
||||
margin-right: 20px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@ -24,31 +23,31 @@ const Indicator = styled.div`
|
||||
props.isSelected ? 'var(--pf-global--active-color--100)' : null};
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
const Contents = styled.div`
|
||||
padding: 10px 20px;
|
||||
`;
|
||||
|
||||
class SelectableCard extends Component {
|
||||
render() {
|
||||
const { label, onClick, isSelected, dataCy } = this.props;
|
||||
const Description = styled.p`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
return (
|
||||
<SelectableItem
|
||||
onClick={onClick}
|
||||
onKeyPress={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
<Label>{label}</Label>
|
||||
</SelectableItem>
|
||||
);
|
||||
}
|
||||
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
||||
return (
|
||||
<SelectableItem
|
||||
onClick={onClick}
|
||||
onKeyPress={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
<Contents>
|
||||
<b>{label}</b>
|
||||
<Description>{description}</Description>
|
||||
</Contents>
|
||||
</SelectableItem>
|
||||
);
|
||||
}
|
||||
|
||||
SelectableCard.propTypes = {
|
||||
@ -59,6 +58,7 @@ SelectableCard.propTypes = {
|
||||
|
||||
SelectableCard.defaultProps = {
|
||||
label: '',
|
||||
description: '',
|
||||
isSelected: false,
|
||||
};
|
||||
|
||||
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 }) {
|
||||
let nodeType;
|
||||
if (node.unifiedJobTemplate) {
|
||||
switch (node.unifiedJobTemplate.unified_job_type) {
|
||||
const type =
|
||||
node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type;
|
||||
switch (type) {
|
||||
case 'job_template':
|
||||
case 'job':
|
||||
nodeType = i18n._(t`Job Template`);
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
case 'workflow_job':
|
||||
nodeType = i18n._(t`Workflow Job Template`);
|
||||
break;
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
nodeType = i18n._(t`Project Update`);
|
||||
break;
|
||||
case 'inventory_source':
|
||||
case 'inventory_update':
|
||||
nodeType = i18n._(t`Inventory Update`);
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
case 'workflow_approval':
|
||||
nodeType = i18n._(t`Workflow Approval`);
|
||||
break;
|
||||
|
||||
@ -13,43 +13,30 @@ const NodeTypeLetter = styled.foreignObject`
|
||||
|
||||
function WorkflowNodeTypeLetter({ node }) {
|
||||
let nodeTypeLetter;
|
||||
if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) {
|
||||
switch (node.unifiedJobTemplate.type) {
|
||||
case 'job_template':
|
||||
nodeTypeLetter = 'JT';
|
||||
break;
|
||||
case 'project':
|
||||
nodeTypeLetter = 'P';
|
||||
break;
|
||||
case 'inventory_source':
|
||||
nodeTypeLetter = 'I';
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
nodeTypeLetter = 'W';
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
nodeTypeLetter = <PauseIcon />;
|
||||
break;
|
||||
default:
|
||||
nodeTypeLetter = '';
|
||||
}
|
||||
} else if (
|
||||
if (
|
||||
node.unifiedJobTemplate &&
|
||||
node.unifiedJobTemplate.unified_job_type
|
||||
(node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type)
|
||||
) {
|
||||
switch (node.unifiedJobTemplate.unified_job_type) {
|
||||
const ujtType =
|
||||
node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type;
|
||||
switch (ujtType) {
|
||||
case 'job_template':
|
||||
case 'job':
|
||||
nodeTypeLetter = 'JT';
|
||||
break;
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
nodeTypeLetter = 'P';
|
||||
break;
|
||||
case 'inventory_source':
|
||||
case 'inventory_update':
|
||||
nodeTypeLetter = 'I';
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
case 'workflow_job':
|
||||
nodeTypeLetter = 'W';
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
case 'workflow_approval':
|
||||
nodeTypeLetter = <PauseIcon />;
|
||||
break;
|
||||
|
||||
@ -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 (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title="Remove Node"
|
||||
title={i18n._(t`Remove Node`)}
|
||||
isOpen={nodeToDelete}
|
||||
onClose={onCancel}
|
||||
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 { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
|
||||
import { layoutGraph } from '@util/workflow';
|
||||
import ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import DeleteAllNodesModal from './Modals/DeleteAllNodesModal';
|
||||
import LinkModal from './Modals/LinkModal';
|
||||
import LinkDeleteModal from './Modals/LinkDeleteModal';
|
||||
import NodeModal from './Modals/NodeModal/NodeModal';
|
||||
import NodeDeleteModal from './Modals/NodeDeleteModal';
|
||||
import VisualizerGraph from './VisualizerGraph';
|
||||
import VisualizerStartScreen from './VisualizerStartScreen';
|
||||
import VisualizerToolbar from './VisualizerToolbar';
|
||||
import { WorkflowJobTemplatesAPI } from '@api';
|
||||
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
|
||||
import {
|
||||
WorkflowApprovalTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
WorkflowJobTemplateNodesAPI,
|
||||
} from '@api';
|
||||
|
||||
const CenteredContent = styled.div`
|
||||
display: flex;
|
||||
@ -25,7 +36,11 @@ const Wrapper = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
|
||||
const fetchWorkflowNodes = async (
|
||||
templateId,
|
||||
pageNo = 1,
|
||||
workflowNodes = []
|
||||
) => {
|
||||
try {
|
||||
const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
|
||||
page_size: 200,
|
||||
@ -35,36 +50,111 @@ const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
|
||||
return await fetchWorkflowNodes(
|
||||
templateId,
|
||||
pageNo + 1,
|
||||
nodes.concat(data.results)
|
||||
workflowNodes.concat(data.results)
|
||||
);
|
||||
}
|
||||
return nodes.concat(data.results);
|
||||
return workflowNodes.concat(data.results);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
function Visualizer({ template, i18n }) {
|
||||
function Visualizer({ history, template, i18n }) {
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [graphLinks, setGraphLinks] = useState([]);
|
||||
// We'll also need to store the original set of nodes...
|
||||
const [graphNodes, setGraphNodes] = useState([]);
|
||||
const [links, setLinks] = useState([]);
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [linkToDelete, setLinkToDelete] = useState(null);
|
||||
const [linkToEdit, setLinkToEdit] = useState(null);
|
||||
const [nodePositions, setNodePositions] = useState(null);
|
||||
const [nodeToDelete, setNodeToDelete] = useState(null);
|
||||
const [nodeToEdit, setNodeToEdit] = useState(null);
|
||||
const [addingLink, setAddingLink] = useState(false);
|
||||
const [addLinkSourceNode, setAddLinkSourceNode] = useState(null);
|
||||
const [addLinkTargetNode, setAddLinkTargetNode] = useState(null);
|
||||
const [addNodeSource, setAddNodeSource] = useState(null);
|
||||
const [addNodeTarget, setAddNodeTarget] = useState(null);
|
||||
const [nextNodeId, setNextNodeId] = useState(0);
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false);
|
||||
const [showDeleteAllNodesModal, setShowDeleteAllNodesModal] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
|
||||
const startAddNode = (sourceNodeId, targetNodeId = null) => {
|
||||
setAddNodeSource(sourceNodeId);
|
||||
setAddNodeTarget(targetNodeId);
|
||||
};
|
||||
|
||||
const finishAddingNode = newNode => {
|
||||
const newNodes = [...nodes];
|
||||
const newLinks = [...links];
|
||||
newNodes.push({
|
||||
id: nextNodeId,
|
||||
type: 'node',
|
||||
unifiedJobTemplate: newNode.nodeResource,
|
||||
});
|
||||
|
||||
// Ensures that root nodes appear to always run
|
||||
// after "START"
|
||||
if (addNodeSource === 1) {
|
||||
newNode.edgeType = 'always';
|
||||
}
|
||||
|
||||
newLinks.push({
|
||||
source: { id: addNodeSource },
|
||||
target: { id: nextNodeId },
|
||||
edgeType: newNode.edgeType,
|
||||
type: 'link',
|
||||
});
|
||||
if (addNodeTarget) {
|
||||
newLinks.forEach(linkToCompare => {
|
||||
if (
|
||||
linkToCompare.source.id === addNodeSource &&
|
||||
linkToCompare.target.id === addNodeTarget
|
||||
) {
|
||||
linkToCompare.source = { id: nextNodeId };
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setAddNodeSource(null);
|
||||
setAddNodeTarget(null);
|
||||
setNextNodeId(nextNodeId + 1);
|
||||
setNodes(newNodes);
|
||||
setLinks(newLinks);
|
||||
};
|
||||
|
||||
const startEditNode = nodeToEdit => {
|
||||
setNodeToEdit(nodeToEdit);
|
||||
};
|
||||
|
||||
const finishEditingNode = editedNode => {
|
||||
const newNodes = [...nodes];
|
||||
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
|
||||
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
|
||||
matchingNode.isEdited = true;
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setNodeToEdit(null);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const cancelNodeForm = () => {
|
||||
setAddNodeSource(null);
|
||||
setAddNodeTarget(null);
|
||||
setNodeToEdit(null);
|
||||
};
|
||||
|
||||
const deleteNode = () => {
|
||||
const nodeId = nodeToDelete.id;
|
||||
const newGraphNodes = [...graphNodes];
|
||||
const newGraphLinks = [...graphLinks];
|
||||
const newNodes = [...nodes];
|
||||
const newLinks = [...links];
|
||||
|
||||
// Remove the node from the array
|
||||
for (let i = newGraphNodes.length; i--; ) {
|
||||
if (newGraphNodes[i].id === nodeId) {
|
||||
newGraphNodes.splice(i, 1);
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
|
||||
|
||||
// Update the links
|
||||
const parents = [];
|
||||
@ -72,8 +162,8 @@ function Visualizer({ template, i18n }) {
|
||||
const linkParentMapping = {};
|
||||
|
||||
// Remove any links that reference this node
|
||||
for (let i = newGraphLinks.length; i--; ) {
|
||||
const link = newGraphLinks[i];
|
||||
for (let i = newLinks.length; i--; ) {
|
||||
const link = newLinks[i];
|
||||
|
||||
if (!linkParentMapping[link.target.id]) {
|
||||
linkParentMapping[link.target.id] = [];
|
||||
@ -87,7 +177,7 @@ function Visualizer({ template, i18n }) {
|
||||
} else if (link.target.id === nodeId) {
|
||||
parents.push(link.source.id);
|
||||
}
|
||||
newGraphLinks.splice(i, 1);
|
||||
newLinks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +188,7 @@ function Visualizer({ template, i18n }) {
|
||||
// We only want to create a link from the start node to this node if it
|
||||
// doesn't have any other parents
|
||||
if (linkParentMapping[child.id].length === 1) {
|
||||
newGraphLinks.push({
|
||||
newLinks.push({
|
||||
source: { id: parentId },
|
||||
target: { id: child.id },
|
||||
edgeType: 'always',
|
||||
@ -106,7 +196,7 @@ function Visualizer({ template, i18n }) {
|
||||
});
|
||||
}
|
||||
} else if (!linkParentMapping[child.id].includes(parentId)) {
|
||||
newGraphLinks.push({
|
||||
newLinks.push({
|
||||
source: { id: parentId },
|
||||
target: { id: child.id },
|
||||
edgeType: child.edgeType,
|
||||
@ -117,51 +207,483 @@ function Visualizer({ template, i18n }) {
|
||||
});
|
||||
// need to track that this node has been deleted if it's not new
|
||||
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setNodeToDelete(null);
|
||||
setGraphNodes(newGraphNodes);
|
||||
setGraphLinks(newGraphLinks);
|
||||
setNodes(newNodes);
|
||||
setLinks(newLinks);
|
||||
};
|
||||
|
||||
const updateLink = edgeType => {
|
||||
const newLinks = [...links];
|
||||
newLinks.forEach(link => {
|
||||
if (
|
||||
link.source.id === linkToEdit.source.id &&
|
||||
link.target.id === linkToEdit.target.id
|
||||
) {
|
||||
link.edgeType = edgeType;
|
||||
}
|
||||
});
|
||||
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setLinkToEdit(null);
|
||||
setLinks(newLinks);
|
||||
};
|
||||
|
||||
const startDeleteLink = link => {
|
||||
let parentMap = {};
|
||||
links.forEach(link => {
|
||||
if (!parentMap[link.target.id]) {
|
||||
parentMap[link.target.id] = [];
|
||||
}
|
||||
parentMap[link.target.id].push(link.source.id);
|
||||
});
|
||||
|
||||
link.isConvergenceLink = parentMap[link.target.id].length > 1;
|
||||
|
||||
setLinkToDelete(link);
|
||||
};
|
||||
|
||||
const deleteLink = () => {
|
||||
const newLinks = [...links];
|
||||
|
||||
for (let i = newLinks.length; i--; ) {
|
||||
const link = newLinks[i];
|
||||
|
||||
if (
|
||||
link.source.id === linkToDelete.source.id &&
|
||||
link.target.id === linkToDelete.target.id
|
||||
) {
|
||||
newLinks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkToDelete.isConvergenceLink) {
|
||||
// Add a new link from the start node to the orphaned node
|
||||
newLinks.push({
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: linkToDelete.target.id,
|
||||
},
|
||||
edgeType: 'always',
|
||||
type: 'link',
|
||||
});
|
||||
}
|
||||
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setLinkToDelete(null);
|
||||
setLinks(newLinks);
|
||||
};
|
||||
|
||||
const selectSourceNodeForLinking = sourceNode => {
|
||||
const newNodes = [...nodes];
|
||||
let parentMap = {};
|
||||
let invalidLinkTargetIds = [];
|
||||
// Find and mark any ancestors as disabled to prevent cycles
|
||||
links.forEach(link => {
|
||||
// id=1 is our artificial root node so we don't care about that
|
||||
if (link.source.id !== 1) {
|
||||
if (link.source.id === sourceNode.id) {
|
||||
// Disables direct children from the add link process
|
||||
invalidLinkTargetIds.push(link.target.id);
|
||||
}
|
||||
if (!parentMap[link.target.id]) {
|
||||
parentMap[link.target.id] = [];
|
||||
}
|
||||
parentMap[link.target.id].push(link.source.id);
|
||||
}
|
||||
});
|
||||
|
||||
let getAncestors = id => {
|
||||
if (parentMap[id]) {
|
||||
parentMap[id].forEach(parentId => {
|
||||
invalidLinkTargetIds.push(parentId);
|
||||
getAncestors(parentId);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getAncestors(sourceNode.id);
|
||||
|
||||
// Filter out the duplicates
|
||||
invalidLinkTargetIds
|
||||
.filter((element, index, array) => index === array.indexOf(element))
|
||||
.forEach(ancestorId => {
|
||||
newNodes.forEach(node => {
|
||||
if (node.id === ancestorId) {
|
||||
node.isInvalidLinkTarget = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setAddLinkSourceNode(sourceNode);
|
||||
setAddingLink(true);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const selectTargetNodeForLinking = targetNode => {
|
||||
setAddLinkTargetNode(targetNode);
|
||||
};
|
||||
|
||||
const addLink = edgeType => {
|
||||
const newLinks = [...links];
|
||||
const newNodes = [...nodes];
|
||||
|
||||
newNodes.forEach(node => {
|
||||
node.isInvalidLinkTarget = false;
|
||||
});
|
||||
|
||||
newLinks.push({
|
||||
source: { id: addLinkSourceNode.id },
|
||||
target: { id: addLinkTargetNode.id },
|
||||
edgeType,
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
newLinks.forEach((link, index) => {
|
||||
if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) {
|
||||
newLinks.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (!unsavedChanges) {
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setAddLinkSourceNode(null);
|
||||
setAddLinkTargetNode(null);
|
||||
setAddingLink(false);
|
||||
setLinks(newLinks);
|
||||
};
|
||||
|
||||
const cancelNodeLink = () => {
|
||||
const newNodes = [...nodes];
|
||||
|
||||
newNodes.forEach(node => {
|
||||
node.isInvalidLinkTarget = false;
|
||||
});
|
||||
|
||||
setAddLinkSourceNode(null);
|
||||
setAddLinkTargetNode(null);
|
||||
setAddingLink(false);
|
||||
setNodes(newNodes);
|
||||
};
|
||||
|
||||
const deleteAllNodes = () => {
|
||||
setAddLinkSourceNode(null);
|
||||
setAddLinkTargetNode(null);
|
||||
setAddingLink(false);
|
||||
setNodes(
|
||||
nodes.map(node => {
|
||||
if (node.id !== 1) {
|
||||
node.isDeleted = true;
|
||||
}
|
||||
|
||||
return node;
|
||||
})
|
||||
);
|
||||
setLinks([]);
|
||||
setShowDeleteAllNodesModal(false);
|
||||
};
|
||||
|
||||
const handleVisualizerClose = () => {
|
||||
if (unsavedChanges) {
|
||||
setShowUnsavedChangesModal(true);
|
||||
} else {
|
||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisualizerSave = async () => {
|
||||
const nodeRequests = [];
|
||||
const approvalTemplateRequests = [];
|
||||
const originalLinkMap = {};
|
||||
const deletedNodeIds = [];
|
||||
nodes.forEach(node => {
|
||||
if (node.originalNodeObject && !node.isDeleted) {
|
||||
const {
|
||||
id,
|
||||
success_nodes,
|
||||
failure_nodes,
|
||||
always_nodes,
|
||||
} = node.originalNodeObject;
|
||||
originalLinkMap[node.id] = {
|
||||
id,
|
||||
success_nodes,
|
||||
failure_nodes,
|
||||
always_nodes,
|
||||
};
|
||||
}
|
||||
if (node.id !== 1) {
|
||||
// node with id=1 is the artificial start node
|
||||
if (node.isDeleted && node.originalNodeObject) {
|
||||
deletedNodeIds.push(node.originalNodeObject.id);
|
||||
nodeRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id)
|
||||
);
|
||||
} else if (!node.isDeleted && !node.originalNodeObject) {
|
||||
if (node.unifiedJobTemplate.type === 'workflow_approval_template') {
|
||||
nodeRequests.push(
|
||||
WorkflowJobTemplatesAPI.createNode(template.id, {}).then(
|
||||
({ data }) => {
|
||||
node.originalNodeObject = data;
|
||||
originalLinkMap[node.id] = {
|
||||
id: data.id,
|
||||
success_nodes: [],
|
||||
failure_nodes: [],
|
||||
always_nodes: [],
|
||||
};
|
||||
approvalTemplateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
|
||||
data.id,
|
||||
{
|
||||
name: node.unifiedJobTemplate.name,
|
||||
description: node.unifiedJobTemplate.description,
|
||||
timeout: node.unifiedJobTemplate.timeout,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
nodeRequests.push(
|
||||
WorkflowJobTemplatesAPI.createNode(template.id, {
|
||||
unified_job_template: node.unifiedJobTemplate.id,
|
||||
}).then(({ data }) => {
|
||||
node.originalNodeObject = data;
|
||||
originalLinkMap[node.id] = {
|
||||
id: data.id,
|
||||
success_nodes: [],
|
||||
failure_nodes: [],
|
||||
always_nodes: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (node.isEdited) {
|
||||
if (
|
||||
node.unifiedJobTemplate &&
|
||||
(node.unifiedJobTemplate.unified_job_type === 'workflow_approval' ||
|
||||
node.unifiedJobTemplate.type === 'workflow_approval_template')
|
||||
) {
|
||||
if (
|
||||
node.originalNodeObject.summary_fields.unified_job_template
|
||||
.unified_job_type === 'workflow_approval'
|
||||
) {
|
||||
approvalTemplateRequests.push(
|
||||
WorkflowApprovalTemplatesAPI.update(
|
||||
node.originalNodeObject.summary_fields.unified_job_template
|
||||
.id,
|
||||
{
|
||||
name: node.unifiedJobTemplate.name,
|
||||
description: node.unifiedJobTemplate.description,
|
||||
timeout: node.unifiedJobTemplate.timeout,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
approvalTemplateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
|
||||
node.originalNodeObject.id,
|
||||
{
|
||||
name: node.unifiedJobTemplate.name,
|
||||
description: node.unifiedJobTemplate.description,
|
||||
timeout: node.unifiedJobTemplate.timeout,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
nodeRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, {
|
||||
unified_job_template: node.unifiedJobTemplate.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: error handling?
|
||||
await Promise.all(nodeRequests);
|
||||
await Promise.all(approvalTemplateRequests);
|
||||
|
||||
const associateRequests = [];
|
||||
const disassociateRequests = [];
|
||||
const linkMap = {};
|
||||
const newLinks = [];
|
||||
|
||||
links.forEach(link => {
|
||||
if (link.source.id !== 1) {
|
||||
const realLinkSourceId = originalLinkMap[link.source.id].id;
|
||||
const realLinkTargetId = originalLinkMap[link.target.id].id;
|
||||
if (!linkMap[realLinkSourceId]) {
|
||||
linkMap[realLinkSourceId] = {};
|
||||
}
|
||||
linkMap[realLinkSourceId][realLinkTargetId] = link.edgeType;
|
||||
switch (link.edgeType) {
|
||||
case 'success':
|
||||
if (
|
||||
!originalLinkMap[link.source.id].success_nodes.includes(
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
) {
|
||||
newLinks.push(link);
|
||||
}
|
||||
break;
|
||||
case 'failure':
|
||||
if (
|
||||
!originalLinkMap[link.source.id].failure_nodes.includes(
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
) {
|
||||
newLinks.push(link);
|
||||
}
|
||||
break;
|
||||
case 'always':
|
||||
if (
|
||||
!originalLinkMap[link.source.id].always_nodes.includes(
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
) {
|
||||
newLinks.push(link);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [nodeId, node] of Object.entries(originalLinkMap)) {
|
||||
node.success_nodes.forEach(successNodeId => {
|
||||
if (
|
||||
!deletedNodeIds.includes(successNodeId) &&
|
||||
(!linkMap[node.id] ||
|
||||
!linkMap[node.id][successNodeId] ||
|
||||
linkMap[node.id][successNodeId] !== 'success')
|
||||
) {
|
||||
disassociateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.disassociateSuccessNode(
|
||||
node.id,
|
||||
successNodeId
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
node.failure_nodes.forEach(failureNodeId => {
|
||||
if (
|
||||
!deletedNodeIds.includes(failureNodeId) &&
|
||||
(!linkMap[node.id] ||
|
||||
!linkMap[node.id][failureNodeId] ||
|
||||
linkMap[node.id][failureNodeId] !== 'failure')
|
||||
) {
|
||||
disassociateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.disassociateFailuresNode(
|
||||
node.id,
|
||||
failureNodeId
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
node.always_nodes.forEach(alwaysNodeId => {
|
||||
if (
|
||||
!deletedNodeIds.includes(alwaysNodeId) &&
|
||||
(!linkMap[node.id] ||
|
||||
!linkMap[node.id][alwaysNodeId] ||
|
||||
linkMap[node.id][alwaysNodeId] !== 'always')
|
||||
) {
|
||||
disassociateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode(
|
||||
node.id,
|
||||
alwaysNodeId
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: error handling?
|
||||
await Promise.all(disassociateRequests);
|
||||
|
||||
newLinks.forEach(link => {
|
||||
switch (link.edgeType) {
|
||||
case 'success':
|
||||
associateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.associateSuccessNode(
|
||||
originalLinkMap[link.source.id].id,
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'failure':
|
||||
associateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.associateFailureNode(
|
||||
originalLinkMap[link.source.id].id,
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'always':
|
||||
associateRequests.push(
|
||||
WorkflowJobTemplateNodesAPI.associateAlwaysNode(
|
||||
originalLinkMap[link.source.id].id,
|
||||
originalLinkMap[link.target.id].id
|
||||
)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: error handling?
|
||||
await Promise.all(associateRequests);
|
||||
|
||||
// Some nodes (both new and edited) are going to need a followup request to
|
||||
// either create or update an approval job template. This has to happen
|
||||
// after the node has been created
|
||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildGraphArrays = nodes => {
|
||||
const buildGraphArrays = workflowNodes => {
|
||||
const nonRootNodeIds = [];
|
||||
const allNodeIds = [];
|
||||
const arrayOfLinksForChart = [];
|
||||
const nodeIdToChartNodeIdMapping = {};
|
||||
const chartNodeIdToIndexMapping = {};
|
||||
const nodeRef = {};
|
||||
let nodeIdCounter = 1;
|
||||
const arrayOfNodesForChart = [
|
||||
{
|
||||
id: nodeIdCounter,
|
||||
id: 1,
|
||||
unifiedJobTemplate: {
|
||||
name: i18n._(t`START`),
|
||||
},
|
||||
type: 'node',
|
||||
},
|
||||
];
|
||||
nodeIdCounter++;
|
||||
// Assign each node an ID - 0 is reserved for the start node. We need to
|
||||
let nodeIdCounter = 2;
|
||||
// Assign each node an ID - 1 is reserved for the start node. We need to
|
||||
// make sure that we have an ID on every node including new nodes so the
|
||||
// ID returned by the api won't do
|
||||
nodes.forEach(node => {
|
||||
workflowNodes.forEach(node => {
|
||||
node.workflowMakerNodeId = nodeIdCounter;
|
||||
nodeRef[nodeIdCounter] = {
|
||||
originalNodeObject: node,
|
||||
};
|
||||
|
||||
const nodeObj = {
|
||||
index: nodeIdCounter - 1,
|
||||
id: nodeIdCounter,
|
||||
type: 'node',
|
||||
originalNodeObject: node,
|
||||
};
|
||||
|
||||
if (node.summary_fields.job) {
|
||||
nodeObj.job = node.summary_fields.job;
|
||||
}
|
||||
if (node.summary_fields.unified_job_template) {
|
||||
nodeRef[nodeIdCounter].unifiedJobTemplate =
|
||||
node.summary_fields.unified_job_template;
|
||||
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
|
||||
}
|
||||
|
||||
@ -172,7 +694,7 @@ function Visualizer({ template, i18n }) {
|
||||
nodeIdCounter++;
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
workflowNodes.forEach(node => {
|
||||
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
|
||||
node.success_nodes.forEach(nodeId => {
|
||||
const targetIndex =
|
||||
@ -226,14 +748,15 @@ function Visualizer({ template, i18n }) {
|
||||
});
|
||||
});
|
||||
|
||||
setGraphNodes(arrayOfNodesForChart);
|
||||
setGraphLinks(arrayOfLinksForChart);
|
||||
setNodes(arrayOfNodesForChart);
|
||||
setLinks(arrayOfLinksForChart);
|
||||
setNextNodeId(nodeIdCounter);
|
||||
};
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const nodes = await fetchWorkflowNodes(template.id);
|
||||
buildGraphArrays(nodes);
|
||||
const workflowNodes = await fetchWorkflowNodes(template.id);
|
||||
buildGraphArrays(workflowNodes);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
@ -245,9 +768,10 @@ function Visualizer({ template, i18n }) {
|
||||
|
||||
// Update positions of nodes/links
|
||||
useEffect(() => {
|
||||
if (graphNodes) {
|
||||
if (nodes) {
|
||||
const newNodePositions = {};
|
||||
const g = layoutGraph(graphNodes, graphLinks);
|
||||
const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
|
||||
const g = layoutGraph(nonDeletedNodes, links);
|
||||
|
||||
g.nodes().forEach(node => {
|
||||
newNodePositions[node] = g.node(node);
|
||||
@ -255,7 +779,7 @@ function Visualizer({ template, i18n }) {
|
||||
|
||||
setNodePositions(newNodePositions);
|
||||
}
|
||||
}, [graphLinks, graphNodes]);
|
||||
}, [links, nodes]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -276,17 +800,38 @@ function Visualizer({ template, i18n }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Wrapper>
|
||||
<VisualizerToolbar template={template} />
|
||||
{graphLinks.length > 0 ? (
|
||||
<VisualizerToolbar
|
||||
nodes={nodes}
|
||||
template={template}
|
||||
onClose={handleVisualizerClose}
|
||||
onSave={handleVisualizerSave}
|
||||
onDeleteAllClick={() => setShowDeleteAllNodesModal(true)}
|
||||
onKeyToggle={() => setShowKey(!showKey)}
|
||||
keyShown={showKey}
|
||||
onToolsToggle={() => setShowTools(!showTools)}
|
||||
toolsShown={showTools}
|
||||
/>
|
||||
{links.length > 0 ? (
|
||||
<VisualizerGraph
|
||||
links={graphLinks}
|
||||
nodes={graphNodes}
|
||||
links={links}
|
||||
nodes={nodes}
|
||||
nodePositions={nodePositions}
|
||||
readOnly={!template.summary_fields.user_capabilities.edit}
|
||||
onAddNodeClick={startAddNode}
|
||||
onEditNodeClick={startEditNode}
|
||||
onDeleteNodeClick={setNodeToDelete}
|
||||
onLinkEditClick={setLinkToEdit}
|
||||
onDeleteLinkClick={startDeleteLink}
|
||||
onStartAddLinkClick={selectSourceNodeForLinking}
|
||||
onConfirmAddLinkClick={selectTargetNodeForLinking}
|
||||
onCancelAddLinkClick={cancelNodeLink}
|
||||
addingLink={addingLink}
|
||||
addLinkSourceNode={addLinkSourceNode}
|
||||
showKey={showKey}
|
||||
showTools={showTools}
|
||||
/>
|
||||
) : (
|
||||
<VisualizerStartScreen />
|
||||
<VisualizerStartScreen onStartClick={startAddNode} />
|
||||
)}
|
||||
</Wrapper>
|
||||
<NodeDeleteModal
|
||||
@ -294,8 +839,74 @@ function Visualizer({ template, i18n }) {
|
||||
onConfirm={deleteNode}
|
||||
onCancel={() => setNodeToDelete(null)}
|
||||
/>
|
||||
{linkToDelete && (
|
||||
<LinkDeleteModal
|
||||
linkToDelete={linkToDelete}
|
||||
onConfirm={deleteLink}
|
||||
onCancel={() => setLinkToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{linkToEdit && (
|
||||
<LinkModal
|
||||
header={
|
||||
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
|
||||
{/* todo: make title match mockups (display: flex) */}
|
||||
{i18n._(t`Edit Link`)}
|
||||
</Title>
|
||||
}
|
||||
onConfirm={updateLink}
|
||||
onCancel={() => setLinkToEdit(null)}
|
||||
edgeType={linkToEdit.edgeType}
|
||||
/>
|
||||
)}
|
||||
{addLinkSourceNode && addLinkTargetNode && (
|
||||
<LinkModal
|
||||
header={
|
||||
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
|
||||
{/* todo: make title match mockups (display: flex) */}
|
||||
{i18n._(t`Add Link`)}
|
||||
</Title>
|
||||
}
|
||||
onConfirm={addLink}
|
||||
onCancel={cancelNodeLink}
|
||||
/>
|
||||
)}
|
||||
{addNodeSource && (
|
||||
<NodeModal
|
||||
askLinkType={addNodeSource !== 1}
|
||||
title={i18n._(t`Add Node`)}
|
||||
onClose={() => cancelNodeForm()}
|
||||
onSave={finishAddingNode}
|
||||
/>
|
||||
)}
|
||||
{nodeToEdit && (
|
||||
<NodeModal
|
||||
askLinkType={false}
|
||||
node={nodeToEdit}
|
||||
title={i18n._(t`Edit Node`)}
|
||||
onClose={() => cancelNodeForm()}
|
||||
onSave={finishEditingNode}
|
||||
/>
|
||||
)}
|
||||
{showUnsavedChangesModal && (
|
||||
<UnsavedChangesModal
|
||||
onCancel={() => setShowUnsavedChangesModal(false)}
|
||||
onExit={() =>
|
||||
history.push(
|
||||
`/templates/workflow_job_template/${template.id}/details`
|
||||
)
|
||||
}
|
||||
onSaveAndExit={() => handleVisualizerSave()}
|
||||
/>
|
||||
)}
|
||||
{showDeleteAllNodesModal && (
|
||||
<DeleteAllNodesModal
|
||||
onCancel={() => setShowDeleteAllNodesModal(false)}
|
||||
onConfirm={() => deleteAllNodes()}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(Visualizer);
|
||||
export default withI18n()(withRouter(Visualizer));
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import * as d3 from 'd3';
|
||||
import { calcZoomAndFit } from '@util/workflow';
|
||||
import {
|
||||
calcZoomAndFit,
|
||||
constants as wfConstants,
|
||||
getZoomTranslate,
|
||||
} from '@util/workflow';
|
||||
import {
|
||||
WorkflowHelp,
|
||||
WorkflowLinkHelp,
|
||||
@ -10,21 +17,103 @@ import {
|
||||
VisualizerLink,
|
||||
VisualizerNode,
|
||||
VisualizerStartNode,
|
||||
VisualizerKey,
|
||||
VisualizerTools,
|
||||
} from '@screens/Template/WorkflowJobTemplateVisualizer';
|
||||
|
||||
function VizualizerGraph({
|
||||
const PotentialLink = styled.polyline`
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const WorkflowSVG = styled.svg`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background-color: #f6f6f6;
|
||||
`;
|
||||
|
||||
// const KeyWrapper = styled.div`
|
||||
// position: absolute;
|
||||
// right: 20px;
|
||||
// top: 76px;
|
||||
// `;
|
||||
|
||||
// const ToolsWrapper = styled.div`
|
||||
// position: absolute;
|
||||
// right: 200px;
|
||||
// top: 76px;
|
||||
// `;
|
||||
|
||||
function VisualizerGraph({
|
||||
links,
|
||||
nodes,
|
||||
readOnly,
|
||||
nodePositions,
|
||||
onDeleteNodeClick,
|
||||
onAddNodeClick,
|
||||
onEditNodeClick,
|
||||
onLinkEditClick,
|
||||
onDeleteLinkClick,
|
||||
onStartAddLinkClick,
|
||||
onConfirmAddLinkClick,
|
||||
onCancelAddLinkClick,
|
||||
addingLink,
|
||||
addLinkSourceNode,
|
||||
showKey,
|
||||
showTools,
|
||||
i18n,
|
||||
}) {
|
||||
const [helpText, setHelpText] = useState(null);
|
||||
const [nodeHelp, setNodeHelp] = useState();
|
||||
const [linkHelp, setLinkHelp] = useState();
|
||||
const [zoomPercentage, setZoomPercentage] = useState(100);
|
||||
const svgRef = useRef(null);
|
||||
const gRef = useRef(null);
|
||||
|
||||
const drawPotentialLinkToNode = node => {
|
||||
if (node.id !== addLinkSourceNode.id) {
|
||||
const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
|
||||
const sourceNodeY =
|
||||
nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
|
||||
const targetNodeX = nodePositions[node.id].x;
|
||||
const targetNodeY = nodePositions[node.id].y - nodePositions[1].y;
|
||||
const startX = sourceNodeX + wfConstants.nodeW;
|
||||
const startY = sourceNodeY + wfConstants.nodeH / 2;
|
||||
const finishX = targetNodeX;
|
||||
const finishY = targetNodeY + wfConstants.nodeH / 2;
|
||||
|
||||
d3.select('#workflow-potentialLink')
|
||||
.attr('points', `${startX},${startY} ${finishX},${finishY}`)
|
||||
.raise();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackgroundClick = () => {
|
||||
setHelpText(null);
|
||||
onCancelAddLinkClick();
|
||||
};
|
||||
|
||||
const drawPotentialLinkToCursor = e => {
|
||||
const currentTransform = d3.zoomTransform(d3.select(gRef.current).node());
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
|
||||
const sourceNodeY =
|
||||
nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
|
||||
const startX = sourceNodeX + wfConstants.nodeW;
|
||||
const startY = sourceNodeY + wfConstants.nodeH / 2;
|
||||
|
||||
d3.select('#workflow-potentialLink')
|
||||
.attr(
|
||||
'points',
|
||||
`${startX},${startY} ${mouseX / currentTransform.k -
|
||||
currentTransform.x / currentTransform.k},${mouseY /
|
||||
currentTransform.k -
|
||||
currentTransform.y / currentTransform.k}`
|
||||
)
|
||||
.raise();
|
||||
};
|
||||
|
||||
// This is the zoom function called by using the mousewheel/click and drag
|
||||
const zoom = () => {
|
||||
const translation = [d3.event.transform.x, d3.event.transform.y];
|
||||
@ -32,6 +121,74 @@ function VizualizerGraph({
|
||||
'transform',
|
||||
`translate(${translation}) scale(${d3.event.transform.k})`
|
||||
);
|
||||
|
||||
setZoomPercentage(d3.event.transform.k * 100);
|
||||
};
|
||||
|
||||
const handlePan = direction => {
|
||||
let { x: xPos, y: yPos, k: currentScale } = d3.zoomTransform(
|
||||
d3.select(svgRef.current).node()
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
yPos -= 50;
|
||||
break;
|
||||
case 'down':
|
||||
yPos += 50;
|
||||
break;
|
||||
case 'left':
|
||||
xPos -= 50;
|
||||
break;
|
||||
case 'right':
|
||||
xPos += 50;
|
||||
break;
|
||||
default:
|
||||
// Throw an error?
|
||||
break;
|
||||
}
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(xPos, yPos).scale(currentScale)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePanToMiddle = () => {
|
||||
const svgElement = document.getElementById('workflow-svg');
|
||||
const svgBoundingClientRect = svgElement.getBoundingClientRect();
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity
|
||||
.translate(0, svgBoundingClientRect.height / 2 - 30)
|
||||
.scale(1)
|
||||
);
|
||||
|
||||
setZoomPercentage(100);
|
||||
};
|
||||
|
||||
const handleZoomChange = newScale => {
|
||||
const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale);
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(translateX, translateY).scale(newScale)
|
||||
);
|
||||
setZoomPercentage(newScale * 100);
|
||||
};
|
||||
|
||||
const handleFitGraph = () => {
|
||||
const [scaleToFit, yTranslate] = calcZoomAndFit(
|
||||
gRef.current,
|
||||
svgRef.current
|
||||
);
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||
);
|
||||
|
||||
setZoomPercentage(scaleToFit * 100);
|
||||
};
|
||||
|
||||
const zoomRef = d3
|
||||
@ -46,12 +203,17 @@ function VizualizerGraph({
|
||||
|
||||
// Attempt to zoom the graph to fit the available screen space
|
||||
useEffect(() => {
|
||||
const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current);
|
||||
const [scaleToFit, yTranslate] = calcZoomAndFit(
|
||||
gRef.current,
|
||||
svgRef.current
|
||||
);
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||
);
|
||||
|
||||
setZoomPercentage(scaleToFit * 100);
|
||||
// We only want this to run once (when the component mounts)
|
||||
// Including zoomRef.transform in the deps array will cause this to
|
||||
// run very frequently.
|
||||
@ -61,7 +223,7 @@ function VizualizerGraph({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
{(helpText || nodeHelp || linkHelp) && (
|
||||
<WorkflowHelp>
|
||||
{helpText && <p>{helpText}</p>}
|
||||
@ -69,11 +231,38 @@ function VizualizerGraph({
|
||||
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
|
||||
</WorkflowHelp>
|
||||
)}
|
||||
<svg
|
||||
id="workflow-svg"
|
||||
ref={svgRef}
|
||||
css="display: flex; height: 100%; background-color: #f6f6f6;"
|
||||
>
|
||||
<WorkflowSVG id="workflow-svg" ref={svgRef} css="">
|
||||
<defs>
|
||||
<marker
|
||||
id="workflow-triangle"
|
||||
className="WorkflowChart-noPointerEvents"
|
||||
viewBox="0 -5 10 10"
|
||||
refX="10"
|
||||
markerUnits="strokeWidth"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0,-5L10,0L0,5" fill="#93969A" />
|
||||
</marker>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
opacity="0"
|
||||
id="workflow-backround"
|
||||
{...(addingLink && {
|
||||
onMouseMove: e => drawPotentialLinkToCursor(e),
|
||||
onMouseOver: () =>
|
||||
setHelpText(
|
||||
i18n._(
|
||||
t`Click an available node to create a new link. Click outside the graph to cancel.`
|
||||
)
|
||||
),
|
||||
onMouseOut: () => setHelpText(null),
|
||||
onClick: () => handleBackgroundClick(),
|
||||
})}
|
||||
/>
|
||||
<g id="workflow-g" ref={gRef}>
|
||||
{nodePositions && [
|
||||
<VisualizerStartNode
|
||||
@ -81,19 +270,33 @@ function VizualizerGraph({
|
||||
nodePositions={nodePositions}
|
||||
readOnly={readOnly}
|
||||
updateHelpText={setHelpText}
|
||||
addingLink={addingLink}
|
||||
onAddNodeClick={onAddNodeClick}
|
||||
/>,
|
||||
links.map(link => (
|
||||
<VisualizerLink
|
||||
key={`link-${link.source.id}-${link.target.id}`}
|
||||
link={link}
|
||||
nodePositions={nodePositions}
|
||||
updateHelpText={setHelpText}
|
||||
updateLinkHelp={setLinkHelp}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)),
|
||||
links.map(link => {
|
||||
if (
|
||||
nodePositions[link.source.id] &&
|
||||
nodePositions[link.target.id]
|
||||
) {
|
||||
return (
|
||||
<VisualizerLink
|
||||
key={`link-${link.source.id}-${link.target.id}`}
|
||||
link={link}
|
||||
nodePositions={nodePositions}
|
||||
updateHelpText={setHelpText}
|
||||
updateLinkHelp={setLinkHelp}
|
||||
readOnly={readOnly}
|
||||
onLinkEditClick={onLinkEditClick}
|
||||
onDeleteLinkClick={onDeleteLinkClick}
|
||||
addingLink={addingLink}
|
||||
onAddNodeClick={onAddNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
nodes.map(node => {
|
||||
if (node.id > 1) {
|
||||
if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
|
||||
return (
|
||||
<VisualizerNode
|
||||
key={`node-${node.id}`}
|
||||
@ -102,17 +305,49 @@ function VizualizerGraph({
|
||||
updateHelpText={setHelpText}
|
||||
updateNodeHelp={setNodeHelp}
|
||||
readOnly={readOnly}
|
||||
onAddNodeClick={onAddNodeClick}
|
||||
onEditNodeClick={onEditNodeClick}
|
||||
onDeleteNodeClick={onDeleteNodeClick}
|
||||
onStartAddLinkClick={onStartAddLinkClick}
|
||||
onConfirmAddLinkClick={onConfirmAddLinkClick}
|
||||
addingLink={addingLink}
|
||||
isAddLinkSourceNode={
|
||||
addLinkSourceNode && addLinkSourceNode.id === node.id
|
||||
}
|
||||
{...(addingLink && {
|
||||
onMouseOver: () => drawPotentialLinkToNode(node),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
]}
|
||||
{addingLink && (
|
||||
<PotentialLink
|
||||
id="workflow-potentialLink"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth="2"
|
||||
stroke="#93969A"
|
||||
markerEnd="url(#workflow-triangle)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
</Fragment>
|
||||
</WorkflowSVG>
|
||||
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
|
||||
{showTools && (
|
||||
<VisualizerTools
|
||||
zoomPercentage={zoomPercentage}
|
||||
onZoomChange={handleZoomChange}
|
||||
onFitGraph={handleFitGraph}
|
||||
onPan={handlePan}
|
||||
onPanToMiddle={handlePanToMiddle}
|
||||
/>
|
||||
)}
|
||||
{showKey && <VisualizerKey />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VizualizerGraph;
|
||||
export default withI18n()(VisualizerGraph);
|
||||
|
||||
@ -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 styled from 'styled-components';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
|
||||
@ -12,6 +13,10 @@ import {
|
||||
WorkflowActionTooltipItem,
|
||||
} from '@components/Workflow';
|
||||
|
||||
const LinkG = styled.g`
|
||||
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
|
||||
`;
|
||||
|
||||
function VisualizerLink({
|
||||
link,
|
||||
nodePositions,
|
||||
@ -19,6 +24,10 @@ function VisualizerLink({
|
||||
updateHelpText,
|
||||
updateLinkHelp,
|
||||
i18n,
|
||||
onLinkEditClick,
|
||||
onDeleteLinkClick,
|
||||
addingLink,
|
||||
onAddNodeClick,
|
||||
}) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [pathD, setPathD] = useState();
|
||||
@ -30,6 +39,11 @@ function VisualizerLink({
|
||||
<WorkflowActionTooltipItem
|
||||
id="link-add-node"
|
||||
key="add"
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onAddNodeClick(link.source.id, link.target.id);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
updateHelpText(i18n._(t`Add a new node between these two nodes`))
|
||||
}
|
||||
@ -49,6 +63,7 @@ function VisualizerLink({
|
||||
key="edit"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => onLinkEditClick(link)}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -57,6 +72,7 @@ function VisualizerLink({
|
||||
key="delete"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => onDeleteLinkClick(link)}
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -98,11 +114,12 @@ function VisualizerLink({
|
||||
}, [link, nodePositions]);
|
||||
|
||||
return (
|
||||
<g
|
||||
<LinkG
|
||||
className="WorkflowGraph-link"
|
||||
id={`link-${link.source.id}-${link.target.id}`}
|
||||
onMouseEnter={handleLinkMouseEnter}
|
||||
onMouseLeave={handleLinkMouseLeave}
|
||||
ignorePointerEvents={addingLink}
|
||||
>
|
||||
<polygon
|
||||
id={`link-${link.source.id}-${link.target.id}-overlay`}
|
||||
@ -129,7 +146,7 @@ function VisualizerLink({
|
||||
actions={tooltipActions}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</LinkG>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,14 +16,16 @@ import {
|
||||
WorkflowNodeTypeLetter,
|
||||
} from '@components/Workflow';
|
||||
|
||||
// dont need this in this component
|
||||
const NodeG = styled.g`
|
||||
pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
|
||||
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||
`;
|
||||
|
||||
const NodeContents = styled.foreignObject`
|
||||
font-size: 13px;
|
||||
padding: 0px 10px;
|
||||
background-color: ${props =>
|
||||
props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'};
|
||||
`;
|
||||
|
||||
const NodeDefaultLabel = styled.p`
|
||||
@ -42,6 +44,13 @@ function VisualizerNode({
|
||||
readOnly,
|
||||
i18n,
|
||||
onDeleteNodeClick,
|
||||
onStartAddLinkClick,
|
||||
onConfirmAddLinkClick,
|
||||
addingLink,
|
||||
onMouseOver,
|
||||
isAddLinkSourceNode,
|
||||
onAddNodeClick,
|
||||
onEditNodeClick,
|
||||
}) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
@ -49,6 +58,29 @@ function VisualizerNode({
|
||||
const nodeEl = document.getElementById(`node-${node.id}`);
|
||||
nodeEl.parentNode.appendChild(nodeEl);
|
||||
setHovering(true);
|
||||
if (addingLink) {
|
||||
updateHelpText(
|
||||
node.isInvalidLinkTarget
|
||||
? i18n._(
|
||||
t`Invalid link target. Unable to link to children or ancestor nodes. Graph cycles are not supported.`
|
||||
)
|
||||
: i18n._(t`Click to create a new link to this node.`)
|
||||
);
|
||||
onMouseOver(node);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeMouseLeave = () => {
|
||||
setHovering(false);
|
||||
if (addingLink) {
|
||||
updateHelpText(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeClick = () => {
|
||||
if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) {
|
||||
onConfirmAddLinkClick(node);
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetailsAction = (
|
||||
@ -70,6 +102,11 @@ function VisualizerNode({
|
||||
key="add"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onAddNodeClick(node.id);
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -79,6 +116,11 @@ function VisualizerNode({
|
||||
key="edit"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onEditNodeClick(node);
|
||||
}}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -89,6 +131,11 @@ function VisualizerNode({
|
||||
updateHelpText(i18n._(t`Link to an available node`))
|
||||
}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onStartAddLinkClick(node);
|
||||
}}
|
||||
>
|
||||
<LinkIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -97,7 +144,11 @@ function VisualizerNode({
|
||||
key="delete"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => onDeleteNodeClick(node)}
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onDeleteNodeClick(node);
|
||||
}}
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
@ -109,23 +160,32 @@ function VisualizerNode({
|
||||
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
||||
.y - nodePositions[1].y})`}
|
||||
job={node.job}
|
||||
noPointerEvents={isAddLinkSourceNode}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onMouseLeave={handleNodeMouseLeave}
|
||||
>
|
||||
<rect
|
||||
width={wfConstants.nodeW}
|
||||
height={wfConstants.nodeH}
|
||||
rx="2"
|
||||
ry="2"
|
||||
stroke="#93969A"
|
||||
strokeWidth="2px"
|
||||
stroke={
|
||||
hovering && addingLink && !node.isInvalidLinkTarget
|
||||
? '#007ABC'
|
||||
: '#93969A'
|
||||
}
|
||||
strokeWidth="4px"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
<NodeContents
|
||||
height="60"
|
||||
width="180"
|
||||
onMouseEnter={() => updateNodeHelp(node)}
|
||||
onMouseLeave={() => updateNodeHelp(null)}
|
||||
isInvalidLinkTarget={node.isInvalidLinkTarget}
|
||||
{...(!addingLink && {
|
||||
onMouseEnter: () => updateNodeHelp(node),
|
||||
onMouseLeave: () => updateNodeHelp(null),
|
||||
})}
|
||||
onClick={() => handleNodeClick()}
|
||||
>
|
||||
<NodeDefaultLabel>
|
||||
{node.unifiedJobTemplate
|
||||
@ -134,7 +194,7 @@ function VisualizerNode({
|
||||
</NodeDefaultLabel>
|
||||
</NodeContents>
|
||||
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
|
||||
{hovering && (
|
||||
{hovering && !addingLink && (
|
||||
<WorkflowActionTooltip
|
||||
pointX={wfConstants.nodeW}
|
||||
pointY={wfConstants.nodeH / 2}
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { PlusIcon } from '@patternfly/react-icons';
|
||||
import { constants as wfConstants } from '@util/workflow';
|
||||
import {
|
||||
WorkflowActionTooltip,
|
||||
WorkflowActionTooltipItem,
|
||||
} from '@components/Workflow';
|
||||
|
||||
const StartG = styled.g`
|
||||
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
|
||||
`;
|
||||
|
||||
function VisualizerStartNode({
|
||||
updateHelpText,
|
||||
nodePositions,
|
||||
readOnly,
|
||||
i18n,
|
||||
addingLink,
|
||||
onAddNodeClick,
|
||||
}) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
@ -22,11 +30,12 @@ function VisualizerStartNode({
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
<StartG
|
||||
id="node-1"
|
||||
transform={`translate(${nodePositions[1].x},0)`}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
ignorePointerEvents={addingLink}
|
||||
>
|
||||
<rect
|
||||
width={wfConstants.rootW}
|
||||
@ -50,13 +59,18 @@ function VisualizerStartNode({
|
||||
key="add"
|
||||
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||
onMouseLeave={() => updateHelpText(null)}
|
||||
onClick={() => {
|
||||
updateHelpText(null);
|
||||
setHovering(false);
|
||||
onAddNodeClick(1);
|
||||
}}
|
||||
>
|
||||
<i className="pf-icon pf-icon-add-circle-o" />
|
||||
<PlusIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</StartG>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,6 @@ const StartPanel = styled.div`
|
||||
padding: 60px 80px;
|
||||
border: 1px solid #c7c7c7;
|
||||
background-color: white;
|
||||
color: var(--pf-global--Color--200);
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
@ -29,13 +28,17 @@ const StartPanelWrapper = styled.div`
|
||||
background-color: #f6f6f6;
|
||||
`;
|
||||
|
||||
function StartScreen({ i18n }) {
|
||||
function StartScreen({ i18n, onStartClick }) {
|
||||
return (
|
||||
<div css="flex: 1">
|
||||
<StartPanelWrapper>
|
||||
<StartPanel>
|
||||
<p>{i18n._(t`Please click the Start button to begin.`)}</p>
|
||||
<Button variant="primary" aria-label={i18n._(t`Start`)}>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label={i18n._(t`Start`)}
|
||||
onClick={() => onStartClick(1)}
|
||||
>
|
||||
{i18n._(t`Start`)}
|
||||
</Button>
|
||||
</StartPanel>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge as PFBadge, Button } from '@patternfly/react-core';
|
||||
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
|
||||
import {
|
||||
BookIcon,
|
||||
CompassIcon,
|
||||
@ -22,10 +21,34 @@ const Badge = styled(PFBadge)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
function Toolbar({ history, i18n, template }) {
|
||||
const handleVisualizerCancel = () => {
|
||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||
};
|
||||
const ActionButton = styled(Button)`
|
||||
padding: 6px 10px;
|
||||
margin: 0px 6px;
|
||||
border: none;
|
||||
&:hover {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pf-m-active {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
function Toolbar({
|
||||
i18n,
|
||||
template,
|
||||
onClose,
|
||||
onSave,
|
||||
nodes = [],
|
||||
onDeleteAllClick,
|
||||
onKeyToggle,
|
||||
keyShown,
|
||||
onToolsToggle,
|
||||
toolsShown,
|
||||
}) {
|
||||
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -52,35 +75,54 @@ function Toolbar({ history, i18n, template }) {
|
||||
}}
|
||||
>
|
||||
<div>{i18n._(t`Total Nodes`)}</div>
|
||||
<Badge isRead>0</Badge>
|
||||
<Badge isRead>{totalNodes}</Badge>
|
||||
<VerticalSeparator />
|
||||
<Button variant="plain">
|
||||
<CompassIcon />
|
||||
</Button>
|
||||
<Button variant="plain">
|
||||
<WrenchIcon />
|
||||
</Button>
|
||||
<Button variant="plain">
|
||||
<Tooltip content={i18n._(t`Toggle Key`)} position="bottom">
|
||||
<ActionButton
|
||||
variant="plain"
|
||||
onClick={onKeyToggle}
|
||||
isActive={keyShown}
|
||||
>
|
||||
<CompassIcon />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
|
||||
<ActionButton
|
||||
variant="plain"
|
||||
onClick={onToolsToggle}
|
||||
isActive={toolsShown}
|
||||
>
|
||||
<WrenchIcon />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<ActionButton variant="plain" isDisabled>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button variant="plain">
|
||||
</ActionButton>
|
||||
<ActionButton variant="plain" isDisabled>
|
||||
<BookIcon />
|
||||
</Button>
|
||||
<Button variant="plain">
|
||||
</ActionButton>
|
||||
<ActionButton variant="plain" isDisabled>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
<Button variant="plain">
|
||||
<TrashAltIcon />
|
||||
</Button>
|
||||
</ActionButton>
|
||||
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom">
|
||||
<ActionButton
|
||||
variant="plain"
|
||||
isDisabled={totalNodes === 0}
|
||||
aria-label={i18n._(t`Delete all nodes`)}
|
||||
onClick={onDeleteAllClick}
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<VerticalSeparator />
|
||||
<Button variant="primary" onClick={handleVisualizerCancel}>
|
||||
<Button variant="primary" onClick={onSave}>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
<VerticalSeparator />
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Close`)}
|
||||
onClick={handleVisualizerCancel}
|
||||
onClick={onClose}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
@ -90,4 +132,4 @@ function Toolbar({ history, i18n, template }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(withRouter(Toolbar));
|
||||
export default withI18n()(Toolbar);
|
||||
|
||||
@ -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 VisualizerLink } from './VisualizerLink';
|
||||
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,
|
||||
};
|
||||
|
||||
export function calcZoomAndFit(gRef) {
|
||||
export function calcZoomAndFit(gRef, svgRef) {
|
||||
const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node());
|
||||
const gBoundingClientRect = d3
|
||||
.select(gRef)
|
||||
.node()
|
||||
.getBoundingClientRect();
|
||||
|
||||
gBoundingClientRect.height = gBoundingClientRect.height / currentScale;
|
||||
gBoundingClientRect.width = gBoundingClientRect.width / currentScale;
|
||||
|
||||
const gBBoxDimensions = d3
|
||||
.select(gRef)
|
||||
.node()
|
||||
@ -45,7 +49,7 @@ export function calcZoomAndFit(gRef) {
|
||||
yTranslate =
|
||||
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
|
||||
2 -
|
||||
gBBoxDimensions.y * scaleToFit;
|
||||
(gBBoxDimensions.y / currentScale) * scaleToFit;
|
||||
}
|
||||
|
||||
return [scaleToFit, yTranslate];
|
||||
@ -172,3 +176,29 @@ export function layoutGraph(nodes, links) {
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
export function getZoomTranslate(svgRef, newScale) {
|
||||
const svgElement = document.getElementById('workflow-svg');
|
||||
const svgBoundingClientRect = svgElement.getBoundingClientRect();
|
||||
const current = d3.zoomTransform(d3.select(svgRef).node());
|
||||
const origScale = current.k;
|
||||
const unscaledOffsetX =
|
||||
(current.x +
|
||||
(svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) /
|
||||
2) /
|
||||
origScale;
|
||||
const unscaledOffsetY =
|
||||
(current.y +
|
||||
(svgBoundingClientRect.height * origScale -
|
||||
svgBoundingClientRect.height) /
|
||||
2) /
|
||||
origScale;
|
||||
const translateX =
|
||||
unscaledOffsetX * newScale -
|
||||
(newScale * svgBoundingClientRect.width - svgBoundingClientRect.width) / 2;
|
||||
const translateY =
|
||||
unscaledOffsetY * newScale -
|
||||
(newScale * svgBoundingClientRect.height - svgBoundingClientRect.height) /
|
||||
2;
|
||||
return [translateX, translateY];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user