+
+ {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`)}
+
+
+
(
+
+ )}
+ />
+
+ );
+}
+
+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 && }
)}
-
-
+
+
+ {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 (
+
+
+
+
+ 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}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
-
+
{i18n._(t`Save`)}
@@ -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 (
+
+
+
+
+ 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];
+}