From caaefef9004a798bb5d6b1f2f3244860c629b278 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 30 Mar 2020 17:23:22 -0400 Subject: [PATCH] Add modal to show a preview of node prompt values --- .../components/PromptDetail/PromptDetail.jsx | 153 ++++++++++++++++ .../PromptDetail/PromptDetail.test.jsx | 125 +++++++++++++ .../src/components/PromptDetail/index.js | 1 + .../Modals/NodeModals/NodeViewModal.jsx | 80 +++++++- .../Modals/NodeModals/NodeViewModal.test.jsx | 171 ++++++++++++++++-- 5 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 awx/ui_next/src/components/PromptDetail/PromptDetail.jsx create mode 100644 awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx create mode 100644 awx/ui_next/src/components/PromptDetail/index.js 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..ee47c9d9a4 --- /dev/null +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { shape } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Chip, ChipGroup } from '@patternfly/react-core'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import { DetailList, Detail } from '@components/DetailList'; +import styled from 'styled-components'; + +const PromptHeader = styled.h2` + font-weight: bold; +`; + +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 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 ( + <> + + + + + + + {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..5e7285b08d --- /dev/null +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx @@ -0,0 +1,125 @@ +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', +}; + +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/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/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx index 51d4401c99..03bea68be7 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,90 @@ -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 { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; function NodeViewModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); + const { nodeToView } = useContext(WorkflowStateContext); + const { unifiedJobTemplate } = nodeToView; + const jobType = + unifiedJobTemplate.unified_job_type || unifiedJobTemplate.type; + + const { + result: launchConfig, + isLoading, + error, + request: fetchLaunchConfig, + } = useRequest( + useCallback(async () => { + const readLaunch = ['workflow_job', 'workflow_job_template'].includes( + jobType + ) + ? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id) + : JobTemplatesAPI.readLaunch(unifiedJobTemplate.id); + + const { data } = await readLaunch; + + return data; + }, [jobType, unifiedJobTemplate]), + {} + ); + + useEffect(() => { + if ( + ['workflow_job', 'workflow_job_template', 'job', 'job_template'].includes( + jobType + ) + ) { + fetchLaunchConfig(); + } + }, [jobType, fetchLaunchConfig]); + + const handleEdit = () => { + dispatch({ type: 'SET_NODE_TO_VIEW', value: null }); + dispatch({ type: 'SET_NODE_TO_EDIT', value: nodeToView }); + }; + + let Content; + + if (isLoading) { + Content = ; + } else if (error) { + 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..f0a9fc5456 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,167 @@ 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({}); +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', + }, + }, + }; + + 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', + }, + }, + }; + + 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', + }, + }, + }; + + 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(); }); }); });