From de55af6ae6416f85dfaf4cec08b450e296d471f4 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 13 Jan 2020 11:13:40 -0500 Subject: [PATCH] Fully functioning workflow editor without read-only view modal and without prompting. --- awx/ui_next/src/api/index.js | 6 + .../api/models/WorkflowApprovalTemplates.js | 10 + .../api/models/WorkflowJobTemplateNodes.js | 56 ++ .../src/api/models/WorkflowJobTemplates.js | 4 + .../components/AddRole/AddResourceRole.jsx | 2 +- awx/ui_next/src/components/AddRole/index.js | 1 - .../HorizontalSeparator.jsx | 14 + .../HorizontalSeparator.test.jsx | 11 + .../components/HorizontalSeparator/index.js | 1 + .../SelectableCard.jsx | 48 +- .../SelectableCard.test.jsx | 0 .../src/components/SelectableCard/index.js | 1 + .../components/Workflow/WorkflowNodeHelp.jsx | 9 +- .../Workflow/WorkflowNodeTypeLetter.jsx | 33 +- .../Modals/DeleteAllNodesModal.jsx | 42 ++ .../Modals/LinkDeleteModal.jsx | 48 ++ .../Modals/LinkModal.jsx | 72 ++ .../Modals/NodeDeleteModal.jsx | 2 +- .../Modals/NodeModal/ApprovalPreviewStep.jsx | 49 ++ .../NodeModal/InventorySyncPreviewStep.jsx | 39 + .../NodeModal/JobTemplatePreviewStep.jsx | 185 +++++ .../Modals/NodeModal/NodeApprovalStep.jsx | 161 ++++ .../Modals/NodeModal/NodeModal.jsx | 310 ++++++++ .../Modals/NodeModal/NodeNextButton.jsx | 26 + .../Modals/NodeModal/NodeResourceStep.jsx | 120 +++ .../Modals/NodeModal/NodeTypeStep.jsx | 105 +++ .../NodeModal/ProjectSyncPreviewStep.jsx | 39 + .../WorkflowJobTemplatePreviewStep.jsx | 43 ++ .../Modals/UnsavedChangesModal.jsx | 43 ++ .../Visualizer.jsx | 713 ++++++++++++++++-- .../VisualizerGraph.jsx | 283 ++++++- .../VisualizerKey.jsx | 116 +++ .../VisualizerLink.jsx | 21 +- .../VisualizerNode.jsx | 76 +- .../VisualizerStartNode.jsx | 20 +- .../VisualizerStartScreen.jsx | 9 +- .../VisualizerToolbar.jsx | 92 ++- .../VisualizerTools.jsx | 122 +++ .../WorkflowJobTemplateVisualizer/index.js | 2 + awx/ui_next/src/util/workflow.jsx | 34 +- 40 files changed, 2799 insertions(+), 169 deletions(-) create mode 100644 awx/ui_next/src/api/models/WorkflowApprovalTemplates.js create mode 100644 awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js create mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx create mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx create mode 100644 awx/ui_next/src/components/HorizontalSeparator/index.js rename awx/ui_next/src/components/{AddRole => SelectableCard}/SelectableCard.jsx (59%) rename awx/ui_next/src/components/{AddRole => SelectableCard}/SelectableCard.test.jsx (100%) create mode 100644 awx/ui_next/src/components/SelectableCard/index.js create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index f9acea4211..cf033c16ae 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -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, }; diff --git a/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js b/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js new file mode 100644 index 0000000000..83b14784ab --- /dev/null +++ b/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js @@ -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; diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js new file mode 100644 index 0000000000..dfb434c831 --- /dev/null +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -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; diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js index 07da2531f4..bb0e53f7d5 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js @@ -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; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2a1189dfb6..8576e18bbe 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -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 => diff --git a/awx/ui_next/src/components/AddRole/index.js b/awx/ui_next/src/components/AddRole/index.js index 806e172146..52e9ec78d4 100644 --- a/awx/ui_next/src/components/AddRole/index.js +++ b/awx/ui_next/src/components/AddRole/index.js @@ -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'; diff --git a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx new file mode 100644 index 0000000000..b1646660fe --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx @@ -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 = () => ; + +export default HorizontalSeparator; diff --git a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx new file mode 100644 index 0000000000..c02794494b --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx @@ -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(); + expect(wrapper).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/HorizontalSeparator/index.js b/awx/ui_next/src/components/HorizontalSeparator/index.js new file mode 100644 index 0000000000..7f9fe23413 --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/index.js @@ -0,0 +1 @@ +export { default } from './HorizontalSeparator'; diff --git a/awx/ui_next/src/components/AddRole/SelectableCard.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx similarity index 59% rename from awx/ui_next/src/components/AddRole/SelectableCard.jsx rename to awx/ui_next/src/components/SelectableCard/SelectableCard.jsx index 475af3d2ce..a1dfa28ae3 100644 --- a/awx/ui_next/src/components/AddRole/SelectableCard.jsx +++ b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx @@ -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 ( - - - - - ); - } +function SelectableCard({ label, description, onClick, isSelected, dataCy }) { + return ( + + + + {label} + {description} + + + ); } SelectableCard.propTypes = { @@ -59,6 +58,7 @@ SelectableCard.propTypes = { SelectableCard.defaultProps = { label: '', + description: '', isSelected: false, }; diff --git a/awx/ui_next/src/components/AddRole/SelectableCard.test.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.test.jsx similarity index 100% rename from awx/ui_next/src/components/AddRole/SelectableCard.test.jsx rename to awx/ui_next/src/components/SelectableCard/SelectableCard.test.jsx diff --git a/awx/ui_next/src/components/SelectableCard/index.js b/awx/ui_next/src/components/SelectableCard/index.js new file mode 100644 index 0000000000..1253d7447c --- /dev/null +++ b/awx/ui_next/src/components/SelectableCard/index.js @@ -0,0 +1 @@ +export { default as SelectableCard } from './SelectableCard'; diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx index 8522c0bff8..17524b720e 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -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; diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx index 557f32f202..013c1ed88d 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -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 = ; - 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 = ; break; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx new file mode 100644 index 0000000000..86a19f586f --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx @@ -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 ( + onConfirm()} + > + {i18n._(t`Remove`)} + , + , + ]} + > +

+ {i18n._( + t`Are you sure you want to remove all the nodes in this workflow?` + )} +

+
+ ); +} + +export default withI18n()(DeleteAllNodesModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx new file mode 100644 index 0000000000..db8222b8b8 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx @@ -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 ( + onConfirm()} + > + {i18n._(t`Remove`)} + , + , + ]} + > +

{i18n._(t`Are you sure you want to remove this link?`)}

+ {!linkToDelete.isConvergenceLink && ( + +
+

+ {i18n._( + t`Removing this link will orphan the rest of the branch and cause it to be executed immediately on launch.` + )} +

+
+ )} +
+ ); +} + +export default withI18n()(LinkDeleteModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx new file mode 100644 index 0000000000..6c76c80e4f --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx @@ -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 ( + onConfirm(newEdgeType)} + > + {i18n._(t`Save`)} + , + , + ]} + > + + { + setNewEdgeType(value); + }} + /> + + + ); +} + +export default withI18n()(LinkModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx index fd89907c68..5d98bc2550 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx @@ -8,7 +8,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { return ( + + {i18n._(t`Approval Node`)} + + + + + + + + + + ); +} + +export default withI18n()(ApprovalPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx new file mode 100644 index 0000000000..d385169117 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx @@ -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 ( +
+ + {i18n._(t`Inventory Sync Node`)} + + + + + + +
+ ); +} + +export default withI18n()(InventorySyncPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx new file mode 100644 index 0000000000..c04daa8e58 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx @@ -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 ( +
+ + {i18n._(t`Job Template Node`)} + + + + + + {/* + + {summary_fields.inventory ? ( + + ) : ( + !ask_inventory_on_launch && + renderMissingDataDetail(i18n._(t`Inventory`)) + )} + {summary_fields.project ? ( + + {summary_fields.project + ? summary_fields.project.name + : i18n._(t`Deleted`)} + + } + /> + ) : ( + renderMissingDataDetail(i18n._(t`Project`)) + )} + + + + + + {createdBy && ( + + )} + {modifiedBy && ( + + )} + + + {host_config_key && ( + + + + + )} + {renderOptionsField && ( + + )} + {summary_fields.credentials && + summary_fields.credentials.length > 0 && ( + + {summary_fields.credentials.map(c => ( + + ))} + + } + /> + )} + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {instanceGroups.length > 0 && ( + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + )} + {job_tags && job_tags.length > 0 && ( + + {job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} + + } + /> + )} + {skip_tags && skip_tags.length > 0 && ( + + {skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} + + } + /> + )} */} + + +
+ ); +} + +export default withI18n()(JobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx new file mode 100644 index 0000000000..b6905a077c --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx @@ -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 ( +
+ + {i18n._(t`Approval Node`)} + + + ( +
+ + { + const isValid = + form && + (!form.touched[field.name] || !form.errors[field.name]); + + return ( + + { + updateName(value); + field.onChange(event); + }} + autoFocus + /> + + ); + }} + /> + + + ( + + { + updateDescription(value); + field.onChange(event); + }} + /> + + )} + /> + + + +
+ ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) * 60 + + Number(form.values.timeoutSeconds) + ); + field.onChange(event); + }} + /> + min + + )} + /> + ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) + + Number(form.values.timeoutMinutes) * 60 + ); + field.onChange(event); + }} + /> + sec + + )} + /> +
+
+
+
+ )} + /> +
+ ); +} + +export default withI18n()(NodeApprovalStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx new file mode 100644 index 0000000000..777e73d7fa --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -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: ( + + ), + enableNext: nodeType !== null, + }, + ...(showResourceStep + ? [ + { + name: i18n._(t`Select Node Resource`), + key: 'node_resource', + enableNext: nodeResource !== null, + component: ( + + ), + }, + ] + : []), + ...(showApprovalStep + ? [ + { + name: i18n._(t`Configure Approval`), + key: 'approval', + component: ( + + ), + enableNext: approvalName !== '', + }, + ] + : []), + ...(showPreviewStep + ? [ + { + name: i18n._(t`Preview`), + key: 'preview', + component: ( + <> + {nodeType === 'approval' && ( + + )} + {nodeType === 'job_template' && ( + + )} + {nodeType === 'inventory_source_sync' && ( + + )} + {nodeType === 'project_sync' && ( + + )} + {nodeType === 'workflow_job_template' && ( + + )} + + ), + enableNext: true, + }, + ] + : []), + ]; + + steps.forEach((step, n) => { + step.id = n + 1; + }); + + const CustomFooter = ( + + + {({ activeStep, onNext, onBack, onClose }) => ( + <> + + {activeStep && activeStep.id !== 1 && ( + + )} + + + )} + + + ); + + return ( + + ); +} + +export default withI18n()(NodeModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx new file mode 100644 index 0000000000..0d617ec821 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx @@ -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 ( + + ); +} + +export default withI18n()(NodeNextButton); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx new file mode 100644 index 0000000000..623dae669c --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx @@ -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 ( + + + {headerText} + +

{i18n._(t`Select a resource to be executed from the list below.`)}

+ {nodeResource && ( + updateNodeResource(null)} + selected={[nodeResource]} + /> + )} + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx new file mode 100644 index 0000000000..6d0e84fd34 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx @@ -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 ( +
+ {askLinkType && ( + <> + + {i18n._(t`Run`)} + +

+ {i18n._( + t`Specify the conditions under which this node should be executed` + )} +

+ + updateLinkType('success')} + /> + updateLinkType('failure')} + /> + updateLinkType('always')} + /> + + + )} + + {i18n._(t`Node Type`)} + + + updateNodeType('job_template')} + /> + updateNodeType('workflow_job_template')} + /> + updateNodeType('project_sync')} + /> + updateNodeType('inventory_source_sync')} + /> + updateNodeType('approval')} + /> + +
+ ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx new file mode 100644 index 0000000000..596e6eb905 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx @@ -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 ( +
+ + {i18n._(t`Project Sync Node`)} + + + + + + +
+ ); +} + +export default withI18n()(ProjectPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx new file mode 100644 index 0000000000..d016248c62 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx @@ -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 ( +
+ + {i18n._(t`Workflow Job Template Node`)} + + + + + + +
+ ); +} + +export default withI18n()(WorkflowJobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx new file mode 100644 index 0000000000..caab1b23c3 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx @@ -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 ( + + {i18n._(t`Exit`)} + , + , + ]} + > +

+ + Are you sure you want to exit the Workflow Creator without saving your + changes? + +

+
+ ); +} + +export default withI18n()(UnsavedChangesModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 6c3bb30994..ef3041dbbf 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -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 ( - - {graphLinks.length > 0 ? ( + setShowDeleteAllNodesModal(true)} + onKeyToggle={() => setShowKey(!showKey)} + keyShown={showKey} + onToolsToggle={() => setShowTools(!showTools)} + toolsShown={showTools} + /> + {links.length > 0 ? ( ) : ( - + )} setNodeToDelete(null)} /> + {linkToDelete && ( + setLinkToDelete(null)} + /> + )} + {linkToEdit && ( + + {/* todo: make title match mockups (display: flex) */} + {i18n._(t`Edit Link`)} + + } + onConfirm={updateLink} + onCancel={() => setLinkToEdit(null)} + edgeType={linkToEdit.edgeType} + /> + )} + {addLinkSourceNode && addLinkTargetNode && ( + + {/* todo: make title match mockups (display: flex) */} + {i18n._(t`Add Link`)} + + } + onConfirm={addLink} + onCancel={cancelNodeLink} + /> + )} + {addNodeSource && ( + cancelNodeForm()} + onSave={finishAddingNode} + /> + )} + {nodeToEdit && ( + cancelNodeForm()} + onSave={finishEditingNode} + /> + )} + {showUnsavedChangesModal && ( + setShowUnsavedChangesModal(false)} + onExit={() => + history.push( + `/templates/workflow_job_template/${template.id}/details` + ) + } + onSaveAndExit={() => handleVisualizerSave()} + /> + )} + {showDeleteAllNodesModal && ( + setShowDeleteAllNodesModal(false)} + onConfirm={() => deleteAllNodes()} + /> + )} ); } -export default withI18n()(Visualizer); +export default withI18n()(withRouter(Visualizer)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 76e528cab4..b9eda4f24e 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -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 ( - + <> {(helpText || nodeHelp || linkHelp) && ( {helpText &&

{helpText}

} @@ -69,11 +231,38 @@ function VizualizerGraph({ {linkHelp && }
)} - + + + + + + + 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(), + })} + /> {nodePositions && [ , - links.map(link => ( - - )), + links.map(link => { + if ( + nodePositions[link.source.id] && + nodePositions[link.target.id] + ) { + return ( + + ); + } + return null; + }), nodes.map(node => { - if (node.id > 1) { + if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) { return ( drawPotentialLinkToNode(node), + })} /> ); } return null; }), ]} + {addingLink && ( + + )} - -
+ +
+ {showTools && ( + + )} + {showKey && } +
+ ); } -export default VizualizerGraph; +export default withI18n()(VisualizerGraph); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx new file mode 100644 index 0000000000..74bed8a577 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx @@ -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 ( + +
+ {i18n._(t`Key`)} +
+ +
  • + JT + {i18n._(t`Job Template`)} +
  • +
  • + W + {i18n._(t`Workflow`)} +
  • +
  • + I + {i18n._(t`Inventory Sync`)} +
  • +
  • + P + {i18n._(t`Project Sync`)} +
  • +
  • + + + + {i18n._(t`Approval`)} +
  • +
  • + + {i18n._(t`Warning`)} +
  • +
  • + + {i18n._(t`On Success`)} +
  • +
  • + + {i18n._(t`On Failure`)} +
  • +
  • + + {i18n._(t`Always`)} +
  • +
    +
    + ); +} + +export default withI18n()(VisualizerKey); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index 38b452f033..1887012634 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -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({ { + 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)} > , @@ -57,6 +72,7 @@ function VisualizerLink({ key="delete" onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => onDeleteLinkClick(link)} > , @@ -98,11 +114,12 @@ function VisualizerLink({ }, [link, nodePositions]); return ( - )} - + ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 80ce5161d2..fa7f85d855 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -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); + }} > , @@ -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); + }} > , @@ -89,6 +131,11 @@ function VisualizerNode({ updateHelpText(i18n._(t`Link to an available node`)) } onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onStartAddLinkClick(node); + }} > , @@ -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); + }} > , @@ -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} > updateNodeHelp(node)} - onMouseLeave={() => updateNodeHelp(null)} + isInvalidLinkTarget={node.isInvalidLinkTarget} + {...(!addingLink && { + onMouseEnter: () => updateNodeHelp(node), + onMouseLeave: () => updateNodeHelp(null), + })} + onClick={() => handleNodeClick()} > {node.unifiedJobTemplate @@ -134,7 +194,7 @@ function VisualizerNode({ {node.unifiedJobTemplate && } - {hovering && ( + {hovering && !addingLink && ( (props.ignorePointerEvents ? 'none' : 'auto')}; +`; + function VisualizerStartNode({ updateHelpText, nodePositions, readOnly, i18n, + addingLink, + onAddNodeClick, }) { const [hovering, setHovering] = useState(false); @@ -22,11 +30,12 @@ function VisualizerStartNode({ }; return ( - setHovering(false)} + ignorePointerEvents={addingLink} > updateHelpText(i18n._(t`Add a new node`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onAddNodeClick(1); + }} > - + , ]} /> )} - + ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx index 8a13cd707a..ea3e156264 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx @@ -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 (

    {i18n._(t`Please click the Start button to begin.`)}

    -
    diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index dee2e8130b..c7ad6e08b7 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -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 (
    @@ -52,35 +75,54 @@ function Toolbar({ history, i18n, template }) { }} >
    {i18n._(t`Total Nodes`)}
    - 0 + {totalNodes} - - - - - - + + + + + + - @@ -90,4 +132,4 @@ function Toolbar({ history, i18n, template }) { ); } -export default withI18n()(withRouter(Toolbar)); +export default withI18n()(Toolbar); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx new file mode 100644 index 0000000000..1be36d1851 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx @@ -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 ( + +
    + {i18n._(t`Tools`)} +
    + + + onFitGraph()} css="margin-right: 30px;" /> + + + zoomOut()} css="margin-right: 10px;" /> + + onZoomChange(parseInt(event.target.value) / 100)} + > + + zoomIn()} css="margin: 0px 25px 0px 10px;" /> + + + + onPan('left')} /> + + + + onPan('up')} /> + + + onPanToMiddle()} /> + + + onPan('down')} /> + + + + onPan('right')} /> + + + +
    + ); +} + +export default withI18n()(VisualizerTools); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js index c593ae9701..7cff003e08 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js @@ -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'; diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx index 63191237ad..a914aeda80 100644 --- a/awx/ui_next/src/util/workflow.jsx +++ b/awx/ui_next/src/util/workflow.jsx @@ -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]; +}