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(); }); }); });