diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
new file mode 100644
index 0000000000..e0df62172a
--- /dev/null
+++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import { shape } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t, Trans } from '@lingui/macro';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+
+import { Chip, ChipGroup } from '@patternfly/react-core';
+import { VariablesDetail } from '@components/CodeMirrorInput';
+import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
+
+import PromptProjectDetail from './PromptProjectDetail';
+
+const PromptHeader = styled.h2`
+ font-weight: bold;
+ margin: var(--pf-global--spacer--lg) 0;
+`;
+
+function hasPromptData(launchData) {
+ return (
+ launchData.ask_credential_on_launch ||
+ launchData.ask_diff_mode_on_launch ||
+ launchData.ask_inventory_on_launch ||
+ launchData.ask_job_type_on_launch ||
+ launchData.ask_limit_on_launch ||
+ launchData.ask_scm_branch_on_launch ||
+ launchData.ask_skip_tags_on_launch ||
+ launchData.ask_tags_on_launch ||
+ launchData.ask_variables_on_launch ||
+ launchData.ask_verbosity_on_launch
+ );
+}
+
+function formatTimeout(timeout) {
+ if (typeof timeout === 'undefined' || timeout === null) {
+ return null;
+ }
+ const minutes = Math.floor(timeout / 60);
+ const seconds = timeout - Math.floor(timeout / 60) * 60;
+ return (
+ <>
+ {minutes} min {seconds} sec
+ >
+ );
+}
+
+function PromptDetail({ i18n, resource, launchConfig = {} }) {
+ const { defaults = {} } = launchConfig;
+ const VERBOSITY = {
+ 0: i18n._(t`0 (Normal)`),
+ 1: i18n._(t`1 (Verbose)`),
+ 2: i18n._(t`2 (More Verbose)`),
+ 3: i18n._(t`3 (Debug)`),
+ 4: i18n._(t`4 (Connection Debug)`),
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {resource?.summary_fields?.organization && (
+
+ {resource?.summary_fields?.organization.name}
+
+ }
+ />
+ )}
+
+ {/* TODO: Add JT, WFJT, Inventory Source Details */}
+ {resource?.type === 'project' && (
+
+ )}
+
+
+
+
+
+ {hasPromptData(launchConfig) && (
+ <>
+ {i18n._(t`Prompted Values`)}
+
+ {launchConfig.ask_job_type_on_launch && (
+
+ )}
+ {launchConfig.ask_credential_on_launch && (
+
+ {defaults?.credentials.map(cred => (
+
+ {cred.name}
+
+ ))}
+
+ }
+ />
+ )}
+ {launchConfig.ask_inventory_on_launch && (
+
+ )}
+ {launchConfig.ask_scm_branch_on_launch && (
+
+ )}
+ {launchConfig.ask_limit_on_launch && (
+
+ )}
+ {launchConfig.ask_verbosity_on_launch && (
+
+ )}
+ {launchConfig.ask_tags_on_launch && (
+
+ {defaults?.job_tags.split(',').map(jobTag => (
+
+ {jobTag}
+
+ ))}
+
+ }
+ />
+ )}
+ {launchConfig.ask_skip_tags_on_launch && (
+
+ {defaults?.skip_tags.split(',').map(skipTag => (
+
+ {skipTag}
+
+ ))}
+
+ }
+ />
+ )}
+ {launchConfig.ask_diff_mode_on_launch && (
+
+ )}
+ {launchConfig.ask_variables_on_launch && (
+
+ )}
+
+ >
+ )}
+ >
+ );
+}
+
+PromptDetail.propTypes = {
+ resource: shape({}).isRequired,
+ launchConfig: shape({}),
+};
+
+export default withI18n()(PromptDetail);
diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
new file mode 100644
index 0000000000..e170bc996c
--- /dev/null
+++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+
+import PromptDetail from './PromptDetail';
+
+const mockTemplate = {
+ name: 'Mock Template',
+ description: 'mock description',
+ unified_job_type: 'job',
+ created: '2019-08-08T19:24:05.344276Z',
+ modified: '2019-08-08T19:24:18.162949Z',
+};
+
+const mockPromptLaunch = {
+ ask_credential_on_launch: true,
+ ask_diff_mode_on_launch: true,
+ ask_inventory_on_launch: true,
+ ask_job_type_on_launch: true,
+ ask_limit_on_launch: true,
+ ask_scm_branch_on_launch: true,
+ ask_skip_tags_on_launch: true,
+ ask_tags_on_launch: true,
+ ask_variables_on_launch: true,
+ ask_verbosity_on_launch: true,
+ defaults: {
+ extra_vars: '---foo: bar',
+ diff_mode: false,
+ limit: 3,
+ job_tags: 'one,two,three',
+ skip_tags: 'skip',
+ job_type: 'run',
+ verbosity: 1,
+ inventory: {
+ name: 'Demo Inventory',
+ id: 1,
+ },
+ credentials: [
+ {
+ id: 1,
+ name: 'Demo Credential',
+ credential_type: 1,
+ passwords_needed: [],
+ },
+ ],
+ scm_branch: '123',
+ },
+};
+
+describe('PromptDetail', () => {
+ describe('With prompt values', () => {
+ let wrapper;
+
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should render successfully', () => {
+ expect(wrapper.find('PromptDetail').length).toBe(1);
+ });
+
+ test('should render expected details', () => {
+ function assertDetail(label, value) {
+ expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
+ }
+
+ expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
+ assertDetail('Name', 'Mock Template');
+ assertDetail('Description', 'mock description');
+ assertDetail('Type', 'job');
+ assertDetail('Job Type', 'run');
+ assertDetail('Credential', 'Demo Credential');
+ assertDetail('Inventory', 'Demo Inventory');
+ assertDetail('SCM Branch', '123');
+ assertDetail('Limit', '3');
+ assertDetail('Verbosity', '1 (Verbose)');
+ assertDetail('Job Tags', 'onetwothree');
+ assertDetail('Skip Tags', 'skip');
+ assertDetail('Diff Mode', 'Off');
+ expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
+ '---foo: bar'
+ );
+ });
+ });
+
+ describe('Without prompt values', () => {
+ let wrapper;
+ beforeAll(() => {
+ wrapper = mountWithContexts();
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should render basic detail values', () => {
+ expect(wrapper.find(`Detail[label="Name"]`).length).toBe(1);
+ expect(wrapper.find(`Detail[label="Description"]`).length).toBe(1);
+ expect(wrapper.find(`Detail[label="Type"]`).length).toBe(1);
+ });
+
+ test('should not render promptable details', () => {
+ function assertNoDetail(label) {
+ expect(wrapper.find(`Detail[label="${label}"]`).length).toBe(0);
+ }
+ [
+ 'Job Type',
+ 'Credential',
+ 'Inventory',
+ 'SCM Branch',
+ 'Limit',
+ 'Verbosity',
+ 'Job Tags',
+ 'Skip Tags',
+ 'Diff Mode',
+ ].forEach(label => assertNoDetail(label));
+ expect(wrapper.find('PromptDetail h2').length).toBe(0);
+ expect(wrapper.find('VariablesDetail').length).toBe(0);
+ });
+ });
+});
diff --git a/awx/ui_next/src/components/PromptDetail/PromptProjectDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptProjectDetail.jsx
new file mode 100644
index 0000000000..d0b2a5774a
--- /dev/null
+++ b/awx/ui_next/src/components/PromptDetail/PromptProjectDetail.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Config } from '@contexts/Config';
+import { List, ListItem } from '@patternfly/react-core';
+
+import { Detail } from '@components/DetailList';
+import CredentialChip from '@components/CredentialChip';
+import { toTitleCase } from '@util/strings';
+
+function PromptProjectDetail({ i18n, resource }) {
+ const {
+ allow_override,
+ custom_virtualenv,
+ local_path,
+ scm_branch,
+ scm_clean,
+ scm_delete_on_update,
+ scm_refspec,
+ scm_type,
+ scm_update_on_launch,
+ scm_update_cache_timeout,
+ scm_url,
+ summary_fields,
+ } = resource;
+
+ let optionsList = '';
+ if (
+ scm_clean ||
+ scm_delete_on_update ||
+ scm_update_on_launch ||
+ allow_override
+ ) {
+ optionsList = (
+
+ {scm_clean && {i18n._(t`Clean`)}}
+ {scm_delete_on_update && (
+ {i18n._(t`Delete on Update`)}
+ )}
+ {scm_update_on_launch && (
+ {i18n._(t`Update Revision on Launch`)}
+ )}
+ {allow_override && (
+ {i18n._(t`Allow Branch Override`)}
+ )}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {summary_fields?.credential?.id && (
+
+ }
+ />
+ )}
+ {optionsList && }
+
+
+
+ {({ project_base_dir }) => (
+
+ )}
+
+
+ >
+ );
+}
+
+export default withI18n()(PromptProjectDetail);
diff --git a/awx/ui_next/src/components/PromptDetail/index.js b/awx/ui_next/src/components/PromptDetail/index.js
new file mode 100644
index 0000000000..652aa66a66
--- /dev/null
+++ b/awx/ui_next/src/components/PromptDetail/index.js
@@ -0,0 +1 @@
+export { default } from './PromptDetail';
diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.js b/awx/ui_next/src/components/Workflow/workflowReducer.js
index 05d8af15fb..1fe635c47e 100644
--- a/awx/ui_next/src/components/Workflow/workflowReducer.js
+++ b/awx/ui_next/src/components/Workflow/workflowReducer.js
@@ -17,6 +17,7 @@ export function initReducer() {
nodes: [],
nodeToDelete: null,
nodeToEdit: null,
+ nodeToView: null,
showDeleteAllNodesModal: false,
showLegend: false,
showTools: false,
@@ -93,6 +94,8 @@ export default function visualizerReducer(state, action) {
return updateLink(state, action.linkType);
case 'UPDATE_NODE':
return updateNode(state, action.node);
+ case 'REFRESH_NODE':
+ return refreshNode(state, action.node);
default:
throw new Error(`Unrecognized action type: ${action.type}`);
}
@@ -607,3 +610,17 @@ function updateNode(state, editedNode) {
unsavedChanges: true,
};
}
+
+function refreshNode(state, refreshedNode) {
+ const { nodeToView, nodes } = state;
+ const newNodes = [...nodes];
+
+ const matchingNode = newNodes.find(node => node.id === nodeToView.id);
+ matchingNode.unifiedJobTemplate = refreshedNode.nodeResource;
+
+ return {
+ ...state,
+ nodes: newNodes,
+ nodeToView: matchingNode,
+ };
+}
diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.test.js b/awx/ui_next/src/components/Workflow/workflowReducer.test.js
index 82aa87cf09..cab793ee5c 100644
--- a/awx/ui_next/src/components/Workflow/workflowReducer.test.js
+++ b/awx/ui_next/src/components/Workflow/workflowReducer.test.js
@@ -16,6 +16,7 @@ const defaultState = {
nodes: [],
nodeToDelete: null,
nodeToEdit: null,
+ nodeToView: null,
showDeleteAllNodesModal: false,
showLegend: false,
showTools: false,
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx
index 51d4401c99..7ca5f4cf63 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx
@@ -1,20 +1,137 @@
-import React, { useContext } from 'react';
-import { WorkflowDispatchContext } from '@contexts/Workflow';
-import { Modal } from '@patternfly/react-core';
+import React, { useContext, useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+
+import { Button, Modal } from '@patternfly/react-core';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import PromptDetail from '@components/PromptDetail';
+import useRequest from '@util/useRequest';
+import {
+ InventorySourcesAPI,
+ JobTemplatesAPI,
+ ProjectsAPI,
+ WorkflowJobTemplatesAPI,
+} from '@api';
+
+function getNodeType(node) {
+ const ujtType = node.type || node.unified_job_type;
+ switch (ujtType) {
+ case 'job_template':
+ case 'job':
+ return ['job_template', JobTemplatesAPI];
+ case 'project':
+ case 'project_update':
+ return ['project_sync', ProjectsAPI];
+ case 'inventory_source':
+ case 'inventory_update':
+ return ['inventory_source_sync', InventorySourcesAPI];
+ case 'workflow_job_template':
+ case 'workflow_job':
+ return ['workflow_job_template', WorkflowJobTemplatesAPI];
+ case 'workflow_approval_template':
+ case 'workflow_approval':
+ return ['approval', null];
+ default:
+ return null;
+ }
+}
function NodeViewModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
+ const { nodeToView } = useContext(WorkflowStateContext);
+ const { unifiedJobTemplate } = nodeToView;
+ const [nodeType, nodeAPI] = getNodeType(unifiedJobTemplate);
+
+ const {
+ result: launchConfig,
+ isLoading: isLaunchConfigLoading,
+ error: launchConfigError,
+ request: fetchLaunchConfig,
+ } = useRequest(
+ useCallback(async () => {
+ const readLaunch =
+ nodeType === 'workflow_job_template'
+ ? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id)
+ : JobTemplatesAPI.readLaunch(unifiedJobTemplate.id);
+ const { data } = await readLaunch;
+ return data;
+ }, [nodeType, unifiedJobTemplate.id]),
+ {}
+ );
+
+ const {
+ result: nodeDetail,
+ isLoading: isNodeDetailLoading,
+ error: nodeDetailError,
+ request: fetchNodeDetail,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await nodeAPI?.readDetail(unifiedJobTemplate.id);
+ return data;
+ }, [nodeAPI, unifiedJobTemplate.id]),
+ null
+ );
+
+ useEffect(() => {
+ if (nodeType === 'workflow_job_template' || nodeType === 'job_template') {
+ fetchLaunchConfig();
+ }
+
+ if (unifiedJobTemplate.unified_job_type && nodeType !== 'approval') {
+ fetchNodeDetail();
+ }
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (nodeDetail) {
+ dispatch({
+ type: 'REFRESH_NODE',
+ node: {
+ nodeResource: nodeDetail,
+ },
+ });
+ }
+ }, [nodeDetail]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleEdit = () => {
+ dispatch({ type: 'SET_NODE_TO_VIEW', value: null });
+ dispatch({ type: 'SET_NODE_TO_EDIT', value: nodeToView });
+ };
+
+ let Content;
+ if (isLaunchConfigLoading || isNodeDetailLoading) {
+ Content = ;
+ } else if (launchConfigError || nodeDetailError) {
+ Content = ;
+ } else {
+ Content = (
+
+ );
+ }
+
return (
dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
+ actions={[
+ ,
+ ]}
>
- Coming soon :)
+ {Content}
);
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx
index fa1a9fc82f..d4f22b14de 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx
@@ -1,22 +1,174 @@
import React from 'react';
-import { WorkflowDispatchContext } from '@contexts/Workflow';
-import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
import NodeViewModal from './NodeViewModal';
-let wrapper;
+jest.mock('@api/models/JobTemplates');
+jest.mock('@api/models/WorkflowJobTemplates');
+WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({});
+WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({});
+JobTemplatesAPI.readLaunch.mockResolvedValue({});
+
const dispatch = jest.fn();
+function waitForLoaded(wrapper) {
+ return waitForElement(
+ wrapper,
+ 'NodeViewModal',
+ el => el.find('ContentLoading').length === 0
+ );
+}
+
describe('NodeViewModal', () => {
- test('Close button dispatches as expected', () => {
- wrapper = mountWithContexts(
-
-
-
- );
- wrapper.find('TimesIcon').simulate('click');
- expect(dispatch).toHaveBeenCalledWith({
- type: 'SET_NODE_TO_VIEW',
- value: null,
+ describe('Workflow job template node', () => {
+ let wrapper;
+ const workflowContext = {
+ nodeToView: {
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'Mock Node',
+ description: '',
+ unified_job_type: 'workflow_job',
+ created: '2019-08-08T19:24:05.344276Z',
+ modified: '2019-08-08T19:24:18.162949Z',
+ },
+ },
+ };
+
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ waitForLoaded(wrapper);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should render prompt detail', () => {
+ expect(wrapper.find('PromptDetail').length).toBe(1);
+ });
+
+ test('should fetch workflow template launch data', () => {
+ expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
+ expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_VIEW',
+ value: null,
+ });
+ });
+
+ test('Edit button dispatches as expected', () => {
+ wrapper.find('button[aria-label="Edit Node"]').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_VIEW',
+ value: null,
+ });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_EDIT',
+ value: workflowContext.nodeToView,
+ });
+ });
+ });
+
+ describe('Job template node', () => {
+ const workflowContext = {
+ nodeToView: {
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'Mock Node',
+ description: '',
+ type: 'job_template',
+ created: '2019-08-08T19:24:05.344276Z',
+ modified: '2019-08-08T19:24:18.162949Z',
+ },
+ },
+ };
+
+ test('should fetch job template launch data', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ waitForLoaded(wrapper);
+ expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
+ expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should show content error when read call unsuccessful', async () => {
+ let wrapper;
+ JobTemplatesAPI.readLaunch.mockRejectedValue(new Error({}));
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ waitForLoaded(wrapper);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+ });
+
+ describe('Project node', () => {
+ const workflowContext = {
+ nodeToView: {
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'Mock Node',
+ description: '',
+ type: 'project_update',
+ created: '2019-08-08T19:24:05.344276Z',
+ modified: '2019-08-08T19:24:18.162949Z',
+ },
+ },
+ };
+
+ test('should not fetch launch data', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ waitForLoaded(wrapper);
+ expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
+ expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
+ wrapper.unmount();
+ jest.clearAllMocks();
});
});
});