diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
index dfb434c831..512316a1ab 100644
--- a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
+++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
@@ -51,6 +51,10 @@ class WorkflowJobTemplateNodes extends Base {
disassociate: true,
});
}
+
+ readCredentials(id) {
+ return this.http.get(`${this.baseUrl}${id}/credentials/`);
+ }
}
export default WorkflowJobTemplateNodes;
diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
index 8576e18bbe..2f6bfc227e 100644
--- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
+++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
@@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Wizard } from '@patternfly/react-core';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import { SelectableCard } from '@components/SelectableCard';
+import { Wizard } from '@components/Wizard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>
diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
index c2e3fe2b2c..2dae4512d5 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
+++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { string, number } from 'prop-types';
+import { string, node, number } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '@components/DetailList';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
@@ -21,7 +21,7 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
}
-function VariablesDetail({ value, label, rows }) {
+function VariablesDetail({ value = '---', label, rows }) {
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
const [currentValue, setCurrentValue] = useState(value || '---');
const [error, setError] = useState(null);
@@ -90,7 +90,7 @@ function VariablesDetail({ value, label, rows }) {
}
VariablesDetail.propTypes = {
value: string.isRequired,
- label: string.isRequired,
+ label: node.isRequired,
rows: number,
};
VariablesDetail.defaultProps = {
diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
index c737c96d44..b1c51a6b8f 100644
--- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
+++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
@@ -1,7 +1,15 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
-import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
+import styled from 'styled-components';
+import {
+ EmptyState as PFEmptyState,
+ EmptyStateBody,
+} from '@patternfly/react-core';
+
+const EmptyState = styled(PFEmptyState)`
+ --pf-c-empty-state--m-lg--MaxWidth: none;
+`;
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ className, i18n }) => (
diff --git a/awx/ui_next/src/components/DetailList/Detail.jsx b/awx/ui_next/src/components/DetailList/Detail.jsx
index 10be98d335..e97c20d896 100644
--- a/awx/ui_next/src/components/DetailList/Detail.jsx
+++ b/awx/ui_next/src/components/DetailList/Detail.jsx
@@ -25,8 +25,15 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
`}
`;
-const Detail = ({ label, value, fullWidth, className, dataCy }) => {
- if (!value && typeof value !== 'number') {
+const Detail = ({
+ label,
+ value,
+ fullWidth,
+ className,
+ dataCy,
+ alwaysVisible,
+}) => {
+ if (!value && typeof value !== 'number' && !alwaysVisible) {
return null;
}
@@ -58,10 +65,12 @@ Detail.propTypes = {
label: node.isRequired,
value: node,
fullWidth: bool,
+ alwaysVisible: bool,
};
Detail.defaultProps = {
value: null,
fullWidth: false,
+ alwaysVisible: false,
};
export default Detail;
diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx
index 2727fc67e6..c452c68657 100644
--- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx
+++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx
@@ -10,14 +10,11 @@ import styled from 'styled-components';
import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)`
- padding-top: 15px;
- padding-bottom: 5px;
- border-bottom: #ebebeb var(--pf-global--BorderWidth--sm) solid;
+ margin: 20px 0px;
align-items: baseline;
`;
const SplitLabelItem = styled(SplitItem)`
- font-size: 14px;
font-weight: bold;
word-break: initial;
`;
diff --git a/awx/ui_next/src/components/Wizard/Wizard.jsx b/awx/ui_next/src/components/Wizard/Wizard.jsx
new file mode 100644
index 0000000000..99e884baad
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/Wizard.jsx
@@ -0,0 +1,9 @@
+import { Wizard } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+Wizard.displayName = 'PFWizard';
+export default styled(Wizard)`
+ .pf-c-data-toolbar__content {
+ padding: 0 !important;
+ }
+`;
diff --git a/awx/ui_next/src/components/Wizard/Wizard.test.jsx b/awx/ui_next/src/components/Wizard/Wizard.test.jsx
new file mode 100644
index 0000000000..00fcbd4b51
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/Wizard.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Wizard from './Wizard';
+
+describe('Wizard', () => {
+ test('renders the expected content', () => {
+ const wrapper = mount();
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/awx/ui_next/src/components/Wizard/index.js b/awx/ui_next/src/components/Wizard/index.js
new file mode 100644
index 0000000000..f07d6622b0
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/index.js
@@ -0,0 +1 @@
+export { default as Wizard } from './Wizard';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx
deleted file mode 100644
index b261008c25..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Title } from '@patternfly/react-core';
-import { DetailList, Detail } from '@components/DetailList';
-import HorizontalSeparator from '@components/HorizontalSeparator';
-
-function ApprovalPreviewStep({ i18n, name, description, timeout, linkType }) {
- let linkTypeValue;
-
- switch (linkType) {
- case 'on_success':
- linkTypeValue = i18n._(t`On Success`);
- break;
- case 'on_failure':
- linkTypeValue = i18n._(t`On Failure`);
- break;
- case 'always':
- linkTypeValue = i18n._(t`Always`);
- break;
- default:
- break;
- }
-
- let timeoutValue = i18n._(t`None`);
-
- if (timeout) {
- const minutes = Math.floor(timeout / 60);
- const seconds = timeout - minutes * 60;
- timeoutValue = i18n._(t`${minutes}min ${seconds}sec`);
- }
-
- return (
-
-
- {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
deleted file mode 100644
index d385169117..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index c04daa8e58..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx
+++ /dev/null
@@ -1,185 +0,0 @@
-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
deleted file mode 100644
index b6905a077c..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx
+++ /dev/null
@@ -1,161 +0,0 @@
-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
index 777e73d7fa..ed7bd6d546 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx
@@ -1,38 +1,26 @@
import React, { useState } from 'react';
+import { withRouter } from 'react-router-dom';
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 NodeTypeStep from './NodeTypeStep/NodeTypeStep';
+import RunStep from './RunStep';
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 { Wizard } from '@components/Wizard';
-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 }) {
+function NodeModal({
+ history,
+ i18n,
+ title,
+ onClose,
+ onSave,
+ node,
+ askLinkType,
+}) {
let defaultNodeType = 'job_template';
let defaultNodeResource = null;
let defaultApprovalName = '';
@@ -82,15 +70,6 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
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(
@@ -100,7 +79,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
defaultApprovalTimeout
);
+ const clearQueryParams = () => {
+ const parts = history.location.search.replace(/^\?/, '').split('&');
+ const otherParts = parts.filter(param =>
+ /^!(job_templates\.|projects\.|inventory_sources\.|workflow_job_templates\.)/.test(
+ param
+ )
+ );
+ history.push(`${history.location.pathname}?${otherParts.join('&')}`);
+ };
+
const handleSaveNode = () => {
+ clearQueryParams();
+
const resource =
nodeType === 'approval'
? {
@@ -120,47 +111,13 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
});
};
- 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 handleCancel = () => {
+ clearQueryParams();
+ onClose();
};
const handleNodeTypeChange = newNodeType => {
setNodeType(newNodeType);
- setShowResourceStep(false);
- setShowApprovalStep(false);
- setShowPreviewStep(false);
setNodeResource(null);
setApprovalName('');
setApprovalDescription('');
@@ -168,101 +125,39 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
};
const steps = [
+ ...(askLinkType
+ ? [
+ {
+ name: i18n._(t`Run Type`),
+ key: 'run_type',
+ component: (
+
+ ),
+ enableNext: linkType !== null,
+ },
+ ]
+ : []),
{
- name: node ? i18n._(t`Node Type`) : i18n._(t`Run/Node Type`),
- key: 'node_type',
+ name: i18n._(t`Node Type`),
+ key: 'node_resource',
+ enableNext:
+ (nodeType !== 'approval' && nodeResource !== null) ||
+ (nodeType === 'approval' && approvalName !== ''),
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) => {
@@ -272,20 +167,25 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
const CustomFooter = (
- {({ activeStep, onNext, onBack, onClose }) => (
+ {({ activeStep, onNext, onBack }) => (
<>
setTriggerNext(triggerNext + 1)}
+ buttonText={
+ activeStep.key === 'node_resource'
+ ? i18n._(t`Save`)
+ : i18n._(t`Next`)
+ }
/>
{activeStep && activeStep.id !== 1 && (
)}
-
);
+ const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title;
+
return (
);
}
-export default withI18n()(NodeModal);
+export default withI18n()(withRouter(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
index 0d617ec821..a941cb33da 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx
@@ -3,7 +3,14 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
-function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) {
+function NodeNextButton({
+ i18n,
+ activeStep,
+ onNext,
+ triggerNext,
+ onClick,
+ buttonText,
+}) {
useEffect(() => {
if (!triggerNext) {
return;
@@ -18,7 +25,7 @@ function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) {
onClick={() => onClick(activeStep)}
isDisabled={!activeStep.enableNext}
>
- {activeStep.key === 'preview' ? i18n._(t`Save`) : i18n._(t`Next`)}
+ {buttonText}
);
}
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
deleted file mode 100644
index 623dae669c..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx
+++ /dev/null
@@ -1,120 +0,0 @@
-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
deleted file mode 100644
index 6d0e84fd34..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx
+++ /dev/null
@@ -1,105 +0,0 @@
-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/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx
new file mode 100644
index 0000000000..7cb0db0347
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx
@@ -0,0 +1,81 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { InventorySourcesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('inventory_sources', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function InventorySourcesList({
+ i18n,
+ history,
+ nodeResource,
+ updateNodeResource,
+}) {
+ const [inventorySources, setInventorySources] = useState([]);
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setInventorySources([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await InventorySourcesAPI.read(params);
+ setInventorySources(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ (
+ updateNodeResource(item)}
+ onDeselect={() => updateNodeResource(null)}
+ isRadio={true}
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ />
+ );
+}
+
+export default withI18n()(withRouter(InventorySourcesList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx
new file mode 100644
index 0000000000..01633cb1d5
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx
@@ -0,0 +1,78 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { JobTemplatesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('job_templates', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) {
+ const [jobTemplates, setJobTemplates] = useState([]);
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setJobTemplates([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await JobTemplatesAPI.read(params, {
+ role_level: 'execute_role',
+ });
+ setJobTemplates(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ (
+ updateNodeResource(item)}
+ onDeselect={() => updateNodeResource(null)}
+ isRadio={true}
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ />
+ );
+}
+
+export default withI18n()(withRouter(JobTemplatesList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx
new file mode 100644
index 0000000000..8052571bc1
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx
@@ -0,0 +1,244 @@
+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 } from '@patternfly/react-core';
+import { Divider } from '@patternfly/react-core/dist/esm/experimental';
+import FormRow from '@components/FormRow';
+import AnsibleSelect from '@components/AnsibleSelect';
+import VerticalSeperator from '@components/VerticalSeparator';
+
+import InventorySourcesList from './InventorySourcesList';
+import JobTemplatesList from './JobTemplatesList';
+import ProjectsList from './ProjectsList';
+import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
+
+const TimeoutInput = styled(TextInput)`
+ width: 200px;
+ :not(:first-of-type) {
+ margin-left: 20px;
+ }
+`;
+
+const TimeoutLabel = styled.p`
+ margin-left: 10px;
+`;
+
+function NodeTypeStep({
+ i18n,
+ nodeType = 'job_template',
+ updateNodeType,
+ nodeResource,
+ updateNodeResource,
+ name,
+ updateName,
+ description,
+ updateDescription,
+ timeout = 0,
+ updateTimeout,
+}) {
+ return (
+ <>
+
+
{i18n._(t`Node Type`)}
+
+
+
{
+ updateNodeType(val);
+ }}
+ />
+
+
+
+ {nodeType === 'job_template' && (
+
+ )}
+ {nodeType === 'project_sync' && (
+
+ )}
+ {nodeType === 'inventory_source_sync' && (
+
+ )}
+ {nodeType === 'workflow_job_template' && (
+
+ )}
+ {nodeType === 'approval' && (
+ (
+
+ )}
+ />
+ )}
+ >
+ );
+}
+
+export default withI18n()(NodeTypeStep);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx
new file mode 100644
index 0000000000..5b428f98fe
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx
@@ -0,0 +1,76 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { ProjectsAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('projects', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function ProjectsList({ i18n, history, nodeResource, updateNodeResource }) {
+ const [projects, setProjects] = useState([]);
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setProjects([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await ProjectsAPI.read(params);
+ setProjects(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ (
+ updateNodeResource(item)}
+ onDeselect={() => updateNodeResource(null)}
+ isRadio={true}
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ />
+ );
+}
+
+export default withI18n()(withRouter(ProjectsList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx
new file mode 100644
index 0000000000..c988dd34dd
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx
@@ -0,0 +1,83 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { WorkflowJobTemplatesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('workflow_job_templates', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function WorkflowJobTemplatesList({
+ i18n,
+ history,
+ nodeResource,
+ updateNodeResource,
+}) {
+ const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setWorkflowJobTemplates([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await WorkflowJobTemplatesAPI.read(params, {
+ role_level: 'execute_role',
+ });
+ setWorkflowJobTemplates(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ (
+ updateNodeResource(item)}
+ onDeselect={() => updateNodeResource(null)}
+ isRadio={true}
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ />
+ );
+}
+
+export default withI18n()(withRouter(WorkflowJobTemplatesList));
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
deleted file mode 100644
index 596e6eb905..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-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/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx
new file mode 100644
index 0000000000..ce65f27cdd
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx
@@ -0,0 +1,59 @@
+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 RunStep({ i18n, linkType, updateLinkType }) {
+ return (
+ <>
+
+ {i18n._(t`Run`)}
+
+
+ {i18n._(
+ t`Specify the conditions under which this node should be executed`
+ )}
+
+
+ updateLinkType('success')}
+ />
+ updateLinkType('failure')}
+ />
+ updateLinkType('always')}
+ />
+
+ >
+ );
+}
+
+export default withI18n()(RunStep);
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
deleted file mode 100644
index d016248c62..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-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/NodeViewModal/ApprovalDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx
new file mode 100644
index 0000000000..a99413f471
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { DetailList, Detail } from '@components/DetailList';
+
+function ApprovalDetails({ i18n, node }) {
+ const { name, description, timeout } = node.unifiedJobTemplate;
+
+ let timeoutValue = i18n._(t`None`);
+
+ if (timeout) {
+ const minutes = Math.floor(timeout / 60);
+ const seconds = timeout - minutes * 60;
+ timeoutValue = i18n._(t`${minutes}min ${seconds}sec`);
+ }
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default withI18n()(ApprovalDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx
new file mode 100644
index 0000000000..510d9e591a
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx
@@ -0,0 +1,165 @@
+import React, { useEffect, useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { Trans } from '@lingui/macro';
+import { t } from '@lingui/macro';
+import { InventorySourcesAPI } from '@api';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { DetailList, Detail } from '@components/DetailList';
+import { VariablesDetail } from '@components/CodeMirrorInput';
+import { CredentialChip } from '@components/Chip';
+
+function InventorySourceSyncDetails({ i18n, node }) {
+ const [inventorySource, setInventorySource] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [noReadAccess, setNoReadAccess] = useState(false);
+ const [contentError, setContentError] = useState(null);
+ const [optionsActions, setOptionsActions] = useState(null);
+
+ useEffect(() => {
+ async function fetchInventorySource() {
+ try {
+ const [
+ { data },
+ {
+ data: { actions },
+ },
+ ] = await Promise.all([
+ InventorySourcesAPI.readDetail(node.unifiedJobTemplate.id),
+ InventorySourcesAPI.readOptions(),
+ ]);
+ setInventorySource(data);
+ setOptionsActions(actions);
+ } catch (err) {
+ if (err.response.status === 403) {
+ setNoReadAccess(true);
+ } else {
+ setContentError(err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchInventorySource();
+ }, []);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (contentError) {
+ return ;
+ }
+
+ if (noReadAccess) {
+ return (
+ <>
+
+
+ Your account does not have read access to this inventory source so
+ the displayed details will be limited.
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const {
+ custom_virtualenv,
+ description,
+ group_by,
+ instance_filters,
+ name,
+ source,
+ source_path,
+ source_regions,
+ source_script,
+ source_vars,
+ summary_fields,
+ timeout,
+ verbosity,
+ } = inventorySource;
+
+ let sourceValue = '';
+ let verbosityValue = '';
+
+ optionsActions.GET.source.choices.forEach(choice => {
+ if (choice[0] === source) {
+ sourceValue = choice[1];
+ }
+ });
+
+ optionsActions.GET.verbosity.choices.forEach(choice => {
+ if (choice[0] === verbosity) {
+ verbosityValue = choice[1];
+ }
+ });
+
+ return (
+
+
+
+
+ {summary_fields.inventory && (
+
+ )}
+ {summary_fields.credential && (
+
+ }
+ />
+ )}
+
+
+
+ {/* this should probably be tags built from OPTIONS*/}
+
+
+ {/* this should probably be tags built from OPTIONS */}
+
+
+
+
+
+
+ );
+}
+
+export default withI18n()(InventorySourceSyncDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx
new file mode 100644
index 0000000000..f93b277b15
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx
@@ -0,0 +1,564 @@
+import React, { useEffect, useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { Trans } from '@lingui/macro';
+import { t } from '@lingui/macro';
+import jsyaml from 'js-yaml';
+import styled from 'styled-components';
+import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '@api';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { DetailList, Detail } from '@components/DetailList';
+import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
+import { VariablesDetail } from '@components/CodeMirrorInput';
+
+const Overridden = styled.div`
+ color: var(--pf-global--warning-color--100);
+`;
+
+function JobTemplateDetails({ i18n, node }) {
+ const [jobTemplate, setJobTemplate] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [noReadAccess, setNoReadAccess] = useState(false);
+ const [contentError, setContentError] = useState(null);
+ const [optionsActions, setOptionsActions] = useState(null);
+ const [instanceGroups, setInstanceGroups] = useState([]);
+ const [nodeCredentials, setNodeCredentials] = useState([]);
+ const [launchConf, setLaunchConf] = useState(null);
+
+ useEffect(() => {
+ async function fetchJobTemplate() {
+ try {
+ const [
+ { data },
+ {
+ data: { results: instanceGroups },
+ },
+ { data: launchConf },
+ {
+ data: { actions },
+ },
+ {
+ data: { results: nodeCredentials },
+ },
+ ] = await Promise.all([
+ JobTemplatesAPI.readDetail(node.unifiedJobTemplate.id),
+ JobTemplatesAPI.readInstanceGroups(node.unifiedJobTemplate.id),
+ JobTemplatesAPI.readLaunch(node.unifiedJobTemplate.id),
+ JobTemplatesAPI.readOptions(),
+ WorkflowJobTemplateNodesAPI.readCredentials(
+ node.originalNodeObject.id
+ ),
+ ]);
+ setJobTemplate(data);
+ setInstanceGroups(instanceGroups);
+ setLaunchConf(launchConf);
+ setOptionsActions(actions);
+ setNodeCredentials(nodeCredentials);
+ } catch (err) {
+ if (err.response.status === 403) {
+ setNoReadAccess(true);
+ } else {
+ setContentError(err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchJobTemplate();
+ }, []);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (contentError) {
+ return ;
+ }
+
+ if (noReadAccess) {
+ return (
+ <>
+
+
+ Your account does not have read access to this job template so the
+ displayed details will be limited.
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const {
+ job_type: nodeJobType,
+ limit: nodeLimit,
+ scm_branch: nodeScmBranch,
+ inventory: nodeInventory,
+ verbosity: nodeVerbosity,
+ job_tags: nodeJobTags,
+ skip_tags: nodeSkipTags,
+ diff_mode: nodeDiffMode,
+ extra_data: nodeExtraData,
+ summary_fields: nodeSummaryFields,
+ } = node.originalNodeObject;
+
+ let {
+ ask_job_type_on_launch,
+ ask_limit_on_launch,
+ ask_scm_branch_on_launch,
+ ask_inventory_on_launch,
+ ask_verbosity_on_launch,
+ ask_tags_on_launch,
+ ask_skip_tags_on_launch,
+ ask_diff_mode_on_launch,
+ ask_credential_on_launch,
+ ask_variables_on_launch,
+ description,
+ diff_mode,
+ extra_vars,
+ forks,
+ host_config_key,
+ job_slice_count,
+ job_tags,
+ job_type,
+ name,
+ limit,
+ playbook,
+ skip_tags,
+ timeout,
+ summary_fields,
+ verbosity,
+ scm_branch,
+ inventory,
+ } = jobTemplate;
+
+ const jobTypeOverridden =
+ ask_job_type_on_launch && nodeJobType !== null && job_type !== nodeJobType;
+ const limitOverridden =
+ ask_limit_on_launch && nodeLimit !== null && limit !== nodeLimit;
+ const scmBranchOverridden =
+ ask_scm_branch_on_launch &&
+ nodeScmBranch !== null &&
+ scm_branch !== nodeScmBranch;
+ const inventoryOverridden =
+ ask_inventory_on_launch &&
+ nodeInventory !== null &&
+ inventory !== nodeInventory;
+ const verbosityOverridden =
+ ask_verbosity_on_launch &&
+ nodeVerbosity !== null &&
+ verbosity !== nodeVerbosity;
+ const jobTagsOverridden =
+ ask_tags_on_launch && nodeJobTags !== null && job_tags !== nodeJobTags;
+ const skipTagsOverridden =
+ ask_skip_tags_on_launch &&
+ nodeSkipTags !== null &&
+ skip_tags !== nodeSkipTags;
+ const diffModeOverridden =
+ ask_diff_mode_on_launch &&
+ nodeDiffMode !== null &&
+ diff_mode !== nodeDiffMode;
+ const credentialOverridden =
+ ask_credential_on_launch && nodeCredentials.length > 0;
+ let variablesOverridden = false;
+ let variablesToShow = extra_vars;
+
+ const deepObjectMatch = (obj1, obj2) => {
+ if (obj1 === obj2) {
+ return true;
+ }
+
+ if (
+ obj1 === null ||
+ obj2 === null ||
+ typeof obj1 !== 'object' ||
+ typeof obj2 !== 'object'
+ ) {
+ return false;
+ }
+
+ const obj1Keys = Object.keys(obj1);
+ const obj2Keys = Object.keys(obj2);
+
+ if (obj1Keys.length !== obj2Keys.length) {
+ return false;
+ }
+
+ for (let key of obj1Keys) {
+ if (!obj2Keys.includes(key) || !deepObjectMatch(obj1[key], obj2[key])) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ if (ask_variables_on_launch || launchConf.survey_enabled) {
+ // we need to check to see if the extra vars are different from the defaults
+ // but we'll need to do some normalization. Convert both to JSON objects
+ // and then compare.
+
+ let jsonifiedExtraVars = {};
+ let jsonifiedExtraData = {};
+
+ // extra_vars has to be a string
+ if (typeof extra_vars === 'string') {
+ if (
+ extra_vars === '{}' ||
+ extra_vars === 'null' ||
+ extra_vars === '' ||
+ extra_vars === '""'
+ ) {
+ jsonifiedExtraVars = {};
+ } else {
+ try {
+ // try to turn the string into json
+ jsonifiedExtraVars = JSON.parse(extra_vars);
+ } catch (jsonParseError) {
+ try {
+ // do safeLoad, which well error if not valid yaml
+ jsonifiedExtraVars = jsyaml.safeLoad(extra_vars);
+ } catch (yamlLoadError) {
+ setContentError(yamlLoadError);
+ }
+ }
+ }
+ } else {
+ setContentError(
+ Error(i18n._(t`Error parsing extra variables from the job template`))
+ );
+ }
+
+ // extra_data on a node can be either a string or an object...
+ if (typeof nodeExtraData === 'string') {
+ if (
+ nodeExtraData === '{}' ||
+ nodeExtraData === 'null' ||
+ nodeExtraData === '' ||
+ nodeExtraData === '""'
+ ) {
+ jsonifiedExtraData = {};
+ } else {
+ try {
+ // try to turn the string into json
+ jsonifiedExtraData = JSON.parse(nodeExtraData);
+ } catch (error) {
+ try {
+ // do safeLoad, which well error if not valid yaml
+ jsonifiedExtraData = jsyaml.safeLoad(nodeExtraData);
+ } catch (yamlLoadError) {
+ setContentError(yamlLoadError);
+ }
+ }
+ }
+ } else if (typeof nodeExtraData === 'object') {
+ jsonifiedExtraData = nodeExtraData;
+ } else {
+ setContentError(
+ Error(i18n._(t`Error parsing extra variables from the node`))
+ );
+ }
+
+ if (!deepObjectMatch(jsonifiedExtraVars, jsonifiedExtraData)) {
+ variablesOverridden = true;
+ variablesToShow = jsyaml.safeDump(
+ Object.assign(jsonifiedExtraVars, jsonifiedExtraData)
+ );
+ }
+ }
+
+ let credentialsToShow = summary_fields.credentials;
+
+ if (credentialOverridden) {
+ credentialsToShow = [...nodeCredentials];
+
+ // adds vault_id to the credentials we get back from
+ // fetching the JT
+ launchConf.defaults.credentials.forEach(launchCred => {
+ if (launchCred.vault_id) {
+ summary_fields.credentials[
+ summary_fields.credentials.findIndex(
+ defaultCred => defaultCred.id === launchCred.id
+ )
+ ].vault_id = launchCred.vault_id;
+ }
+ });
+
+ summary_fields.credentials.forEach(defaultCred => {
+ if (
+ !nodeCredentials.some(
+ overrideCredential =>
+ (defaultCred.kind === overrideCredential.kind &&
+ (!defaultCred.vault_id && !overrideCredential.inputs.vault_id)) ||
+ (defaultCred.vault_id &&
+ overrideCredential.inputs.vault_id &&
+ defaultCred.vault_id === overrideCredential.inputs.vault_id)
+ )
+ ) {
+ credentialsToShow.push(defaultCred);
+ }
+ });
+ }
+
+ let verbosityToShow = '';
+
+ optionsActions.GET.verbosity.choices.forEach(choice => {
+ if (
+ verbosityOverridden
+ ? choice[0] === nodeVerbosity
+ : choice[0] === verbosity
+ ) {
+ verbosityToShow = choice[1];
+ }
+ });
+
+ const jobTagsToShow = jobTagsOverridden ? nodeJobTags : job_tags;
+ const skipTagsToShow = skipTagsOverridden ? nodeSkipTags : skip_tags;
+
+ return (
+ <>
+
+
+
+
+ * {i18n._(t`Job Type`)}
+ ) : (
+ i18n._(t`Job Type`)
+ )
+ }
+ value={jobTypeOverridden ? nodeJobType : job_type}
+ />
+ * {i18n._(t`Inventory`)}
+ ) : (
+ i18n._(t`Inventory`)
+ )
+ }
+ value={
+ inventoryOverridden
+ ? nodeSummaryFields.inventory.name
+ : summary_fields.inventory.name
+ }
+ alwaysVisible={inventoryOverridden}
+ />
+ {summary_fields.project && (
+
+ )}
+ * {i18n._(t`SCM Branch`)}
+ ) : (
+ i18n._(t`SCM Branch`)
+ )
+ }
+ value={scmBranchOverridden ? nodeScmBranch : scm_branch}
+ alwaysVisible={scmBranchOverridden}
+ />
+
+
+ * {i18n._(t`Limit`)}
+ ) : (
+ i18n._(t`Limit`)
+ )
+ }
+ value={limitOverridden ? nodeLimit : limit}
+ alwaysVisible={limitOverridden}
+ />
+ * {i18n._(t`Verbosity`)}
+ ) : (
+ i18n._(t`Verbosity`)
+ )
+ }
+ value={verbosityToShow}
+ />
+
+ * {i18n._(t`Show Changes`)}
+ ) : (
+ i18n._(t`Show Changes`)
+ )
+ }
+ value={
+ (diffModeOverridden
+ ? nodeDiffMode
+ : diff_mode)
+ ? i18n._(t`On`)
+ : i18n._(t`Off`)
+ }
+ />
+
+ {host_config_key && (
+ <>
+
+
+ >
+ )}
+ * {i18n._(t`Credentials`)}
+ ) : (
+ i18n._(t`Credentials`)
+ )
+ }
+ value={
+ credentialsToShow.length > 0 && (
+
+ {credentialsToShow.map(c => (
+
+ ))}
+
+ )
+ }
+ alwaysVisible={credentialOverridden}
+ />
+ {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}
+
+ ))}
+
+ }
+ />
+ )}
+ * {i18n._(t`Job Tags`)}
+ ) : (
+ i18n._(t`Job Tags`)
+ )
+ }
+ value={
+ jobTagsOverridden.length > 0 && (
+
+ {jobTagsToShow.split(',').map(jobTag => (
+
+ {jobTag}
+
+ ))}
+
+ )
+ }
+ alwaysVisible={jobTagsOverridden}
+ />
+ * {i18n._(t`Skip Tags`)}
+ ) : (
+ i18n._(t`Skip Tags`)
+ )
+ }
+ value={
+ skipTagsToShow.length > 0 && (
+
+ {skipTagsToShow.split(',').map(skipTag => (
+
+ {skipTag}
+
+ ))}
+
+ )
+ }
+ alwaysVisible={skipTagsOverridden}
+ />
+ * {i18n._(t`Variables`)}
+ ) : (
+ i18n._(t`Variables`)
+ )
+ }
+ value={variablesToShow}
+ rows={4}
+ />
+
+ {(jobTypeOverridden ||
+ limitOverridden ||
+ scmBranchOverridden ||
+ inventoryOverridden ||
+ verbosityOverridden ||
+ jobTagsOverridden ||
+ skipTagsOverridden ||
+ diffModeOverridden ||
+ credentialOverridden ||
+ variablesOverridden) && (
+ <>
+
+
+
+
+ * Values for these fields differ from the job template's default
+
+
+
+ >
+ )}
+ >
+ );
+}
+
+export default withI18n()(JobTemplateDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx
new file mode 100644
index 0000000000..887740d804
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Modal } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import ApprovalDetails from './ApprovalDetails';
+import InventorySourceSyncDetails from './InventorySourceSyncDetails';
+import JobTemplateDetails from './JobTemplateDetails';
+import ProjectSyncDetails from './ProjectSyncDetails';
+import WorkflowJobTemplateDetails from './WorkflowJobTemplateDetails';
+
+function NodeViewModal({ i18n, onClose, node }) {
+ return (
+
+ {(node.unifiedJobTemplate.type === 'job_template' || node.unifiedJobTemplate.unified_job_type === 'job') && (
+
+ )}
+ {(node.unifiedJobTemplate.type === 'workflow_approval_template' || node.unifiedJobTemplate.unified_job_type) === 'workflow_approval' && (
+
+ )}
+ {(node.unifiedJobTemplate.type === 'project' || node.unifiedJobTemplate.unified_job_type === 'project_update') && (
+
+ )}
+ {(node.unifiedJobTemplate.type === 'inventory_source' || node.unifiedJobTemplate.unified_job_type === 'inventory_update') && (
+
+ )}
+ {(node.unifiedJobTemplate.type === 'workflow_job_template' || node.unifiedJobTemplate.unified_job_type === 'workflow_job') && (
+
+ )}
+
+ );
+}
+
+export default withI18n()(NodeViewModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx
new file mode 100644
index 0000000000..759c612940
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx
@@ -0,0 +1,141 @@
+import React, { useEffect, useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { Trans } from '@lingui/macro';
+import { t } from '@lingui/macro';
+import { ProjectsAPI } from '@api';
+import { Config } from '@contexts/Config';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { DetailList, Detail } from '@components/DetailList';
+import { CredentialChip } from '@components/Chip';
+import { toTitleCase } from '@util/strings';
+
+function ProjectSyncDetails({ i18n, node }) {
+ const [project, setProject] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [noReadAccess, setNoReadAccess] = useState(false);
+ const [contentError, setContentError] = useState(null);
+
+ useEffect(() => {
+ async function fetchProject() {
+ try {
+ const { data } = await ProjectsAPI.readDetail(
+ node.unifiedJobTemplate.id
+ );
+ setProject(data);
+ } catch (err) {
+ if (err.response.status === 403) {
+ setNoReadAccess(true);
+ } else {
+ setContentError(err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchProject();
+ }, []);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (contentError) {
+ return ;
+ }
+
+ if (noReadAccess) {
+ return (
+ <>
+
+
+ Your account does not have read access to this project so the
+ displayed details will be limited.
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const {
+ custom_virtualenv,
+ description,
+ local_path,
+ name,
+ scm_branch,
+ scm_refspec,
+ scm_type,
+ scm_update_cache_timeout,
+ scm_url,
+ summary_fields,
+ } = project;
+
+ return (
+
+
+
+
+ {summary_fields.organization && (
+
+ )}
+
+
+
+
+ {summary_fields.credential && (
+
+ }
+ />
+ )}
+
+
+
+ {({ project_base_dir }) => (
+
+ )}
+
+
+
+ );
+}
+
+export default withI18n()(ProjectSyncDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx
new file mode 100644
index 0000000000..5142fd21d3
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { Trans } from '@lingui/macro';
+import { t } from '@lingui/macro';
+import { WorkflowJobTemplatesAPI } from '@api';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { DetailList, Detail } from '@components/DetailList';
+import { ChipGroup, Chip } from '@components/Chip';
+import { VariablesDetail } from '@components/CodeMirrorInput';
+
+function WorkflowJobTemplateDetails({ i18n, node }) {
+ const [workflowJobTemplate, setWorkflowJobTemplate] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [noReadAccess, setNoReadAccess] = useState(false);
+ const [contentError, setContentError] = useState(null);
+
+ useEffect(() => {
+ async function fetchWorkflowJobTemplate() {
+ try {
+ const { data } = await WorkflowJobTemplatesAPI.readDetail(
+ node.unifiedJobTemplate.id
+ );
+ setWorkflowJobTemplate(data);
+ } catch (err) {
+ if (err.response.status === 403) {
+ setNoReadAccess(true);
+ } else {
+ setContentError(err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchWorkflowJobTemplate();
+ }, []);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (contentError) {
+ return ;
+ }
+
+ if (noReadAccess) {
+ return (
+ <>
+
+
+ Your account does not have read access to this workflow job template
+ so the displayed details will be limited.
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const {
+ description,
+ extra_vars,
+ limit,
+ name,
+ scm_branch,
+ summary_fields,
+ } = workflowJobTemplate;
+
+ return (
+
+
+
+
+ {summary_fields.organization && (
+
+ )}
+ {summary_fields.inventory && (
+
+ )}
+
+
+ {summary_fields.labels && summary_fields.labels.results.length > 0 && (
+
+ {summary_fields.labels.results.map(l => (
+
+ {l.name}
+
+ ))}
+
+ }
+ />
+ )}
+
+
+ );
+}
+
+export default withI18n()(WorkflowJobTemplateDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
index ef3041dbbf..0f2c222d08 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
@@ -16,6 +16,7 @@ import VisualizerGraph from './VisualizerGraph';
import VisualizerStartScreen from './VisualizerStartScreen';
import VisualizerToolbar from './VisualizerToolbar';
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
+import NodeViewModal from './Modals/NodeViewModal/NodeViewModal';
import {
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplatesAPI,
@@ -69,6 +70,7 @@ function Visualizer({ history, template, i18n }) {
const [nodePositions, setNodePositions] = useState(null);
const [nodeToDelete, setNodeToDelete] = useState(null);
const [nodeToEdit, setNodeToEdit] = useState(null);
+ const [nodeToView, setNodeToView] = useState(null);
const [addingLink, setAddingLink] = useState(false);
const [addLinkSourceNode, setAddLinkSourceNode] = useState(null);
const [addLinkTargetNode, setAddLinkTargetNode] = useState(null);
@@ -825,6 +827,7 @@ function Visualizer({ history, template, i18n }) {
onStartAddLinkClick={selectSourceNodeForLinking}
onConfirmAddLinkClick={selectTargetNodeForLinking}
onCancelAddLinkClick={cancelNodeLink}
+ onViewNodeClick={setNodeToView}
addingLink={addingLink}
addLinkSourceNode={addLinkSourceNode}
showKey={showKey}
@@ -905,6 +908,9 @@ function Visualizer({ history, template, i18n }) {
onConfirm={() => deleteAllNodes()}
/>
)}
+ {nodeToView && (
+ setNodeToView(null)} />
+ )}
);
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx
index b9eda4f24e..93a4457d15 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx
@@ -31,18 +31,6 @@ const WorkflowSVG = styled.svg`
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,
@@ -56,6 +44,7 @@ function VisualizerGraph({
onStartAddLinkClick,
onConfirmAddLinkClick,
onCancelAddLinkClick,
+ onViewNodeClick,
addingLink,
addLinkSourceNode,
showKey,
@@ -310,6 +299,7 @@ function VisualizerGraph({
onDeleteNodeClick={onDeleteNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
+ onViewNodeClick={onViewNodeClick}
addingLink={addingLink}
isAddLinkSourceNode={
addLinkSourceNode && addLinkSourceNode.id === node.id
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx
index fa7f85d855..3ce9aaf22a 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx
@@ -51,6 +51,7 @@ function VisualizerNode({
isAddLinkSourceNode,
onAddNodeClick,
onEditNodeClick,
+ onViewNodeClick,
}) {
const [hovering, setHovering] = useState(false);
@@ -89,6 +90,11 @@ function VisualizerNode({
key="details"
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
+ onClick={() => {
+ updateHelpText(null);
+ setHovering(false);
+ onViewNodeClick(node);
+ }}
>