diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index 17f7a4d0c1..e846c5fbd8 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -36,6 +36,7 @@ function LaunchButton({ resource, i18n, children, history }) { const [showLaunchPrompt, setShowLaunchPrompt] = useState(false); const [launchConfig, setLaunchConfig] = useState(null); const [surveyConfig, setSurveyConfig] = useState(null); + const [resourceCredentials, setResourceCredentials] = useState([]); const [error, setError] = useState(null); const handleLaunch = async () => { const readLaunch = @@ -56,6 +57,17 @@ function LaunchButton({ resource, i18n, children, history }) { setSurveyConfig(data); } + if ( + launch.ask_credential_on_launch && + resource.type === 'workflow_job_template' + ) { + const { + data: { results: jobTemplateCredentials }, + } = await JobTemplatesAPI.readCredentials(resource.id); + + setResourceCredentials(jobTemplateCredentials); + } + if (canLaunchWithoutPrompt(launch)) { launchWithParams({}); } else { @@ -161,6 +173,7 @@ function LaunchButton({ resource, i18n, children, history }) { resource={resource} onLaunch={launchWithParams} onCancel={() => setShowLaunchPrompt(false)} + resourceDefaultCredentials={resourceCredentials} /> )} diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 8ac45ec8cc..69716edab7 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -18,6 +18,7 @@ function PromptModalForm({ onSubmit, resource, surveyConfig, + resourceDefaultCredentials, }) { const { setFieldTouched, values } = useFormikContext(); @@ -28,7 +29,13 @@ function PromptModalForm({ visitStep, visitAllSteps, contentError, - } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); + } = useLaunchSteps( + launchConfig, + surveyConfig, + resource, + i18n, + resourceDefaultCredentials + ); const handleSubmit = () => { const postValues = {}; @@ -122,6 +129,7 @@ function LaunchPrompt({ onLaunch, resource = {}, surveyConfig, + resourceDefaultCredentials = [], }) { return ( onLaunch(values)}> @@ -132,6 +140,7 @@ function LaunchPrompt({ launchConfig={launchConfig} surveyConfig={surveyConfig} resource={resource} + resourceDefaultCredentials={resourceDefaultCredentials} /> ); diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index 08b777d12d..338ea17256 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -87,7 +87,9 @@ describe('LaunchPrompt', () => { credentials: [ { id: 1, + name: 'cred that prompts', passwords_needed: ['ssh_password'], + credential_type: 1, }, ], }, @@ -122,6 +124,16 @@ describe('LaunchPrompt', () => { }, ], }} + resourceDefaultCredentials={[ + { + id: 5, + name: 'cred that prompts', + credential_type: 1, + inputs: { + password: 'ASK', + }, + }, + ]} /> ); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx index a3659d43f7..a127aaf19a 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx @@ -4,7 +4,8 @@ import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { ToolbarItem } from '@patternfly/react-core'; +import styled from 'styled-components'; +import { Alert, ToolbarItem } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import OptionsList from '../../OptionsList'; @@ -13,7 +14,11 @@ import CredentialChip from '../../CredentialChip'; import ContentError from '../../ContentError'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useRequest from '../../../util/useRequest'; -import { required } from '../../../util/validators'; +import credentialsValidator from './credentialsValidator'; + +const CredentialErrorAlert = styled(Alert)` + margin-bottom: 20px; +`; const QS_CONFIG = getQSConfig('credential', { page: 1, @@ -21,10 +26,21 @@ const QS_CONFIG = getQSConfig('credential', { order_by: 'name', }); -function CredentialsStep({ i18n }) { - const [field, , helpers] = useField({ +function CredentialsStep({ + i18n, + allowCredentialsWithPasswords, + defaultCredentials = [], +}) { + const [field, meta, helpers] = useField({ name: 'credentials', - validate: required(null, i18n), + validate: val => { + return credentialsValidator( + i18n, + defaultCredentials, + allowCredentialsWithPasswords, + val + ); + }, }); const [selectedType, setSelectedType] = useState(null); const history = useHistory(); @@ -87,6 +103,18 @@ function CredentialsStep({ i18n }) { fetchCredentials(); }, [fetchCredentials]); + useEffect(() => { + helpers.setError( + credentialsValidator( + i18n, + defaultCredentials, + allowCredentialsWithPasswords, + field.value + ) + ); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, []); + if (isTypesLoading) { return ; } @@ -97,17 +125,23 @@ function CredentialsStep({ i18n }) { const isVault = selectedType?.kind === 'vault'; - const renderChip = ({ item, removeItem, canDelete }) => ( - removeItem(item)} - isReadOnly={!canDelete} - credential={item} - /> - ); + const renderChip = ({ item, removeItem, canDelete }) => { + return ( + removeItem(item)} + isReadOnly={!canDelete} + credential={item} + /> + ); + }; return ( <> + {meta.error && ( + + )} {types && types.length > 0 && (
@@ -130,57 +164,56 @@ function CredentialsStep({ i18n }) { /> )} - {!isCredentialsLoading && ( - { - const hasSameVaultID = val => - val?.inputs?.vault_id !== undefined && - val?.inputs?.vault_id === item?.inputs?.vault_id; - const hasSameCredentialType = val => - val.credential_type === item.credential_type; - const newItems = field.value.filter(i => - isVault ? !hasSameVaultID(i) : !hasSameCredentialType(i) - ); - newItems.push(item); - helpers.setValue(newItems); - }} - deselectItem={item => { - helpers.setValue(field.value.filter(i => i.id !== item.id)); - }} - renderItemChip={renderChip} - /> - )} + { + const hasSameVaultID = val => + val?.inputs?.vault_id !== undefined && + val?.inputs?.vault_id === item?.inputs?.vault_id; + const hasSameCredentialType = val => + val.credential_type === item.credential_type; + const newItems = field.value.filter(i => + isVault ? !hasSameVaultID(i) : !hasSameCredentialType(i) + ); + newItems.push(item); + helpers.setValue(newItems); + }} + deselectItem={item => { + helpers.setValue(field.value.filter(i => i.id !== item.id)); + }} + renderItemChip={renderChip} + /> ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx index aec480547b..0ff65f2815 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx @@ -9,17 +9,95 @@ jest.mock('../../../api/models/CredentialTypes'); jest.mock('../../../api/models/Credentials'); const types = [ - { id: 1, kind: 'ssh', name: 'SSH' }, - { id: 2, kind: 'cloud', name: 'Ansible Tower' }, - { id: 3, kind: 'vault', name: 'Vault' }, + { id: 1, kind: 'ssh', name: 'SSH', url: '/api/v2/credential_types/1/' }, + { id: 3, kind: 'vault', name: 'Vault', url: '/api/v2/credential_types/3/' }, + { + id: 5, + name: 'Amazon Web Services', + kind: 'cloud', + url: '/api/v2/credential_types/5/', + }, + { + id: 9, + name: 'Google Compute Engine', + kind: 'cloud', + url: '/api/v2/credential_types/9/', + }, ]; const credentials = [ - { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, - { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, - { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, - { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, - { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + { + id: 1, + kind: 'aws', + name: 'Cred 1', + credential_type: 5, + url: '/api/v2/credentials/1/', + inputs: {}, + }, + { + id: 2, + kind: 'ssh', + name: 'Cred 2', + credential_type: 1, + url: '/api/v2/credentials/2/', + inputs: { + password: 'ASK', + }, + }, + { + id: 3, + kind: 'gce', + name: 'Cred 3', + credential_type: 9, + url: '/api/v2/credentials/3/', + inputs: {}, + }, + { + id: 4, + kind: 'ssh', + name: 'Cred 4', + credential_type: 1, + url: '/api/v2/credentials/4/', + inputs: {}, + }, + { + id: 5, + kind: 'ssh', + name: 'Cred 5', + credential_type: 1, + url: '/api/v2/credentials/5/', + inputs: {}, + }, + { + id: 33, + kind: 'vault', + name: 'Cred 33', + credential_type: 3, + url: '/api/v2/credentials/33/', + inputs: { + vault_id: 'foo', + }, + summary_fields: { + credential_type: { + name: 'Vault', + }, + }, + }, + { + id: 34, + kind: 'vault', + name: 'Cred 34', + credential_type: 3, + url: '/api/v2/credentials/34/', + inputs: { + vault_id: 'bar', + }, + summary_fields: { + credential_type: { + name: 'Vault', + }, + }, + }, ]; describe('CredentialsStep', () => { @@ -47,7 +125,7 @@ describe('CredentialsStep', () => { await act(async () => { wrapper = mountWithContexts( - + ); }); @@ -62,7 +140,7 @@ describe('CredentialsStep', () => { await act(async () => { wrapper = mountWithContexts( - + ); }); @@ -76,13 +154,173 @@ describe('CredentialsStep', () => { }); await act(async () => { - wrapper.find('AnsibleSelect').invoke('onChange')({}, 2); + wrapper.find('AnsibleSelect').invoke('onChange')({}, 3); }); expect(CredentialsAPI.read).toHaveBeenCalledWith({ - credential_type: 2, + credential_type: 3, order_by: 'name', page: 1, page_size: 5, }); }); + + test("error should be shown when a credential that prompts for passwords is selected on a step that doesn't allow it", async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(0); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { target: { checked: true } }); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect( + wrapper + .find('Alert') + .text() + .includes('Cred 2') + ).toBe(true); + }); + + test('error should be toggled when default machine credential is removed and then replaced', async () => { + let wrapper; + const selectedCredentials = [ + { + id: 5, + kind: 'ssh', + name: 'Cred 5', + credential_type: 1, + url: '/api/v2/credentials/5/', + inputs: {}, + summary_fields: { + credential_type: { + name: 'Machine', + }, + }, + }, + ]; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(0); + expect(wrapper.find('CredentialChip').length).toBe(1); + await act(async () => { + wrapper.find('button#remove_credential-chip-5').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect( + wrapper + .find('Alert') + .text() + .includes('Machine') + ).toBe(true); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-5"]') + .simulate('change', { target: { checked: true } }); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(0); + }); + + test('error should be toggled when default vault credential is removed and then replaced', async () => { + let wrapper; + const selectedCredentials = [ + { + id: 33, + kind: 'vault', + name: 'Cred 33', + credential_type: 3, + url: '/api/v2/credentials/33/', + inputs: { + vault_id: 'foo', + }, + summary_fields: { + credential_type: { + name: 'Vault', + }, + }, + }, + { + id: 34, + kind: 'vault', + name: 'Cred 34', + credential_type: 3, + url: '/api/v2/credentials/34/', + inputs: { + vault_id: 'bar', + }, + summary_fields: { + credential_type: { + name: 'Vault', + }, + }, + }, + ]; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(0); + expect(wrapper.find('CredentialChip').length).toBe(2); + await act(async () => { + wrapper.find('button#remove_credential-chip-33').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('CredentialChip').length).toBe(1); + expect(wrapper.find('Alert').length).toBe(1); + expect( + wrapper + .find('Alert') + .text() + .includes('Vault | foo') + ).toBe(true); + await act(async () => { + wrapper.find('AnsibleSelect').invoke('onChange')({}, 3); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-33"]') + .simulate('change', { target: { checked: true } }); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index d71d2c0fec..80c5b87bd2 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; +import styled from 'styled-components'; import { Alert } from '@patternfly/react-core'; import { InventoriesAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; @@ -11,6 +12,10 @@ import OptionsList from '../../OptionsList'; import ContentLoading from '../../ContentLoading'; import ContentError from '../../ContentError'; +const InventoryErrorAlert = styled(Alert)` + margin-bottom: 20px; +`; + const QS_CONFIG = getQSConfig('inventory', { page: 1, page_size: 5, @@ -68,6 +73,9 @@ function InventoryStep({ i18n, warningMessage = null }) { return ( <> + {meta.touched && meta.error && ( + + )} {warningMessage} field.onChange(null)} /> - {meta.touched && meta.error && ( - - )} ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/credentialsValidator.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/credentialsValidator.jsx new file mode 100644 index 0000000000..5b8c012f8c --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/credentialsValidator.jsx @@ -0,0 +1,66 @@ +import { t } from '@lingui/macro'; + +const credentialPromptsForPassword = credential => + credential?.inputs?.password === 'ASK' || + credential?.inputs?.ssh_key_unlock === 'ASK' || + credential?.inputs?.become_password === 'ASK' || + credential?.inputs?.vault_password === 'ASK'; + +export default function credentialsValidator( + i18n, + defaultCredentials = [], + allowCredentialsWithPasswords, + selectedCredentials +) { + if (defaultCredentials.length > 0 && selectedCredentials) { + const missingCredentialTypes = []; + defaultCredentials.forEach(defaultCredential => { + if ( + !selectedCredentials.find(selectedCredential => { + return ( + (selectedCredential.credential_type === + defaultCredential.credential_type && + !selectedCredential.inputs.vault_id && + !defaultCredential.inputs.vault_id) || + (selectedCredential.inputs.vault_id && + defaultCredential.inputs.vault_id && + selectedCredential.inputs.vault_id === + defaultCredential.inputs.vault_id) + ); + }) + ) { + missingCredentialTypes.push( + defaultCredential.inputs.vault_id + ? `${defaultCredential.summary_fields.credential_type.name} | ${defaultCredential.inputs.vault_id}` + : defaultCredential.summary_fields.credential_type.name + ); + } + }); + + if (missingCredentialTypes.length > 0) { + return i18n._( + t`Job Template default credentials must be replaced with one of the same type. Please select a credential for the following types in order to proceed: ${missingCredentialTypes.join( + ', ' + )}` + ); + } + } + + if (!allowCredentialsWithPasswords && selectedCredentials) { + const credentialsThatPrompt = []; + selectedCredentials.forEach(selectedCredential => { + if (credentialPromptsForPassword(selectedCredential)) { + credentialsThatPrompt.push(selectedCredential.name); + } + }); + if (credentialsThatPrompt.length > 0) { + return i18n._( + t`Credentials that require passwords on launch are not permitted. Please remove or replace the following credentials with a credential of the same type in order to proceed: ${credentialsThatPrompt.join( + ', ' + )}` + ); + } + } + + return undefined; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index eb3eab3eb7..c390f5dde2 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -1,25 +1,59 @@ import React from 'react'; import { t } from '@lingui/macro'; +import { useField } from 'formik'; import CredentialsStep from './CredentialsStep'; import StepName from './StepName'; +import credentialsValidator from './credentialsValidator'; const STEP_ID = 'credentials'; -export default function useCredentialsStep(launchConfig, resource, i18n) { +export default function useCredentialsStep( + launchConfig, + resource, + resourceDefaultCredentials, + i18n, + allowCredentialsWithPasswords = false +) { + const [field, meta, helpers] = useField('credentials'); + const formError = + !resource || resource?.type === 'workflow_job_template' + ? false + : meta.error; return { - step: getStep(launchConfig, i18n), - initialValues: getInitialValues(launchConfig, resource), + step: getStep( + launchConfig, + i18n, + allowCredentialsWithPasswords, + formError, + resourceDefaultCredentials + ), + initialValues: getInitialValues(launchConfig, resourceDefaultCredentials), isReady: true, contentError: null, - hasError: false, + hasError: launchConfig.ask_credential_on_launch && formError, setTouched: setFieldTouched => { setFieldTouched('credentials', true, false); }, - validate: () => {}, + validate: () => { + helpers.setError( + credentialsValidator( + i18n, + resourceDefaultCredentials, + allowCredentialsWithPasswords, + field.value + ) + ); + }, }; } -function getStep(launchConfig, i18n) { +function getStep( + launchConfig, + i18n, + allowCredentialsWithPasswords, + formError, + resourceDefaultCredentials +) { if (!launchConfig.ask_credential_on_launch) { return null; } @@ -27,21 +61,27 @@ function getStep(launchConfig, i18n) { id: STEP_ID, key: 4, name: ( - + {i18n._(t`Credentials`)} ), - component: , + component: ( + + ), enableNext: true, }; } -function getInitialValues(launchConfig, resource) { +function getInitialValues(launchConfig, resourceDefaultCredentials) { if (!launchConfig.ask_credential_on_launch) { return {}; } return { - credentials: resource?.summary_fields?.credentials || [], + credentials: resourceDefaultCredentials || [], }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 0460a2d7d8..f1d0f65f59 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -43,14 +43,21 @@ export default function useLaunchSteps( launchConfig, surveyConfig, resource, - i18n + i18n, + resourceDefaultCredentials ) { const [visited, setVisited] = useState({}); const [isReady, setIsReady] = useState(false); const { touched, values: formikValues } = useFormikContext(); const steps = [ useInventoryStep(launchConfig, resource, i18n, visited), - useCredentialsStep(launchConfig, resource, i18n), + useCredentialsStep( + launchConfig, + resource, + resourceDefaultCredentials, + i18n, + true + ), useCredentialPasswordsStep( launchConfig, i18n, diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index d0243ac0bd..93fd657a9b 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -26,6 +26,7 @@ function Schedule({ launchConfig, surveyConfig, hasDaysToKeepField, + resourceDefaultCredentials, }) { const { scheduleId } = useParams(); @@ -114,6 +115,7 @@ function Schedule({ resource={resource} launchConfig={launchConfig} surveyConfig={surveyConfig} + resourceDefaultCredentials={resourceDefaultCredentials} /> , diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index a69fb62dba..3d7276cdea 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -22,6 +22,7 @@ function ScheduleEdit({ resource, launchConfig, surveyConfig, + resourceDefaultCredentials, }) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); @@ -131,6 +132,7 @@ function ScheduleEdit({ resource={resource} launchConfig={launchConfig} surveyConfig={surveyConfig} + resourceDefaultCredentials={resourceDefaultCredentials} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index c70eccad6a..227b01dd2f 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -30,8 +30,20 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ SchedulesAPI.readCredentials.mockResolvedValue({ data: { results: [ - { name: 'schedule credential 1', id: 1, kind: 'vault' }, - { name: 'schedule credential 2', id: 2, kind: 'aws' }, + { + name: 'schedule credential 1', + id: 1, + kind: 'vault', + credential_type: 3, + inputs: {}, + }, + { + name: 'schedule credential 2', + id: 2, + kind: 'aws', + credential_type: 4, + inputs: {}, + }, ], count: 2, }, @@ -45,9 +57,9 @@ CredentialsAPI.read.mockResolvedValue({ data: { count: 3, results: [ - { id: 1, name: 'Credential 1', kind: 'ssh', url: '' }, - { id: 2, name: 'Credential 2', kind: 'ssh', url: '' }, - { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + { id: 1, name: 'Credential 1', kind: 'ssh', url: '', credential_type: 1 }, + { id: 2, name: 'Credential 2', kind: 'ssh', url: '', credential_type: 1 }, + { id: 3, name: 'Credential 3', kind: 'ssh', url: '', credential_type: 1 }, ], }, }); @@ -115,6 +127,7 @@ describe('', () => { ], }, }} + resourceDefaultCredentials={[]} launchConfig={{ can_start_without_user_input: false, passwords_needed_to_start: [], @@ -150,6 +163,7 @@ describe('', () => { id: null, }, scm_branch: '', + credentials: [], }, }} surveyConfig={{}} @@ -466,7 +480,7 @@ describe('', () => { .prop('isCurrent') ).toBe(true); - expect(wrapper.find('CredentialChip').length).toBe(3); + expect(wrapper.find('CredentialChip').length).toBe(2); wrapper.update(); diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx index f6785d8fa8..43e2fd7753 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -13,6 +13,7 @@ function Schedules({ launchConfig, surveyConfig, resource, + resourceDefaultCredentials, }) { const match = useRouteMatch(); @@ -32,6 +33,7 @@ function Schedules({ resource={resource} launchConfig={launchConfig} surveyConfig={surveyConfig} + resourceDefaultCredentials={resourceDefaultCredentials} /> @@ -41,6 +43,7 @@ function Schedules({ resource={resource} launchConfig={launchConfig} surveyConfig={surveyConfig} + resourceDefaultCredentials={resourceDefaultCredentials} /> diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index e64eefbbff..6a9c9afe90 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -204,6 +204,7 @@ function ScheduleForm({ resource, launchConfig, surveyConfig, + resourceDefaultCredentials, ...rest }) { const [isWizardOpen, setIsWizardOpen] = useState(false); @@ -297,11 +298,84 @@ function ScheduleForm({ return missingValues; }, [launchConfig, schedule, surveyConfig]); + const hasCredentialsThatPrompt = useCallback(() => { + if (launchConfig?.ask_credential_on_launch) { + if (Object.keys(schedule).length > 0) { + const defaultCredsWithoutOverrides = []; + + const credentialHasOverride = templateDefaultCred => { + let hasOverride = false; + credentials.forEach(nodeCredential => { + if ( + templateDefaultCred.credential_type === + nodeCredential.credential_type + ) { + if ( + (!templateDefaultCred.vault_id && + !nodeCredential.inputs.vault_id) || + (templateDefaultCred.vault_id && + nodeCredential.inputs.vault_id && + templateDefaultCred.vault_id === + nodeCredential.inputs.vault_id) + ) { + hasOverride = true; + } + } + }); + + return hasOverride; + }; + + if (resourceDefaultCredentials) { + resourceDefaultCredentials.forEach(defaultCred => { + if (!credentialHasOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + }); + } + + return ( + credentials + .concat(defaultCredsWithoutOverrides) + .filter(credential => { + let credentialRequiresPass = false; + + Object.entries(credential.inputs).forEach(([key, value]) => { + if (key !== 'vault_id' && value === 'ASK') { + credentialRequiresPass = true; + } + }); + + return credentialRequiresPass; + }).length > 0 + ); + } + + return launchConfig?.defaults?.credentials + ? launchConfig.defaults.credentials.filter( + credential => credential?.passwords_needed.length > 0 + ).length > 0 + : false; + } + + return false; + }, [launchConfig, schedule, credentials, resourceDefaultCredentials]); + useEffect(() => { - if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) { + if ( + isTemplate && + (missingRequiredInventory() || + hasMissingSurveyValue() || + hasCredentialsThatPrompt()) + ) { setIsSaveDisabled(true); } - }, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]); + }, [ + isTemplate, + hasMissingSurveyValue, + missingRequiredInventory, + hasCredentialsThatPrompt, + ]); useEffect(() => { loadScheduleData(); @@ -527,14 +601,14 @@ function ScheduleForm({ surveyConfig={surveyConfig} launchConfig={launchConfig} resource={resource} - onCloseWizard={hasErrors => { + onCloseWizard={() => { setIsWizardOpen(false); - setIsSaveDisabled(hasErrors); }} onSave={() => { setIsWizardOpen(false); setIsSaveDisabled(false); }} + resourceDefaultCredentials={resourceDefaultCredentials} /> )} diff --git a/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx index 0b0e8d2f3c..e86485d6b3 100644 --- a/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx +++ b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx @@ -17,10 +17,10 @@ function SchedulePromptableFields({ onSave, credentials, resource, + resourceDefaultCredentials, i18n, }) { const { - validateForm, setFieldTouched, values, initialValues, @@ -39,12 +39,12 @@ function SchedulePromptableFields({ schedule, resource, i18n, - credentials + credentials, + resourceDefaultCredentials ); const { error, dismissError } = useDismissableError(contentError); const cancelPromptableValues = async () => { - const hasErrors = await validateForm(); resetForm({ values: { ...initialValues, @@ -66,7 +66,7 @@ function SchedulePromptableFields({ timezone: values.timezone, }, }); - onCloseWizard(Object.keys(hasErrors).length > 0); + onCloseWizard(); }; if (error) { @@ -89,13 +89,16 @@ function SchedulePromptableFields({ isOpen onClose={cancelPromptableValues} onSave={onSave} + onBack={async nextStep => { + validateStep(nextStep.id); + }} onNext={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { visitAllSteps(setFieldTouched); } else { visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} onGoToStep={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { @@ -104,7 +107,6 @@ function SchedulePromptableFields({ visitStep(prevStep.prevId, setFieldTouched); validateStep(nextStep.id); } - await validateForm(); }} title={i18n._(t`Prompts`)} steps={ diff --git a/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js index 841aeefa92..6bc0c35291 100644 --- a/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js +++ b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js @@ -13,29 +13,28 @@ export default function useSchedulePromptSteps( schedule, resource, i18n, - scheduleCredentials + scheduleCredentials, + resourceDefaultCredentials ) { - const { - summary_fields: { credentials: resourceCredentials }, - } = resource; const sourceOfValues = (Object.keys(schedule).length > 0 && schedule) || resource; - - sourceOfValues.summary_fields = { - credentials: [...(resourceCredentials || []), ...scheduleCredentials], - ...sourceOfValues.summary_fields, - }; const { resetForm, values } = useFormikContext(); const [visited, setVisited] = useState({}); const steps = [ useInventoryStep(launchConfig, sourceOfValues, i18n, visited), - useCredentialsStep(launchConfig, sourceOfValues, i18n), + useCredentialsStep( + launchConfig, + sourceOfValues, + resourceDefaultCredentials, + i18n + ), useOtherPromptsStep(launchConfig, sourceOfValues, i18n), useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited), ]; const hasErrors = steps.some(step => step.hasError); + steps.push( usePreviewStep( launchConfig, @@ -52,21 +51,61 @@ export default function useSchedulePromptSteps( const isReady = !steps.some(s => !s.isReady); useEffect(() => { - let initialValues = {}; if (launchConfig && surveyConfig && isReady) { + let initialValues = {}; initialValues = steps.reduce((acc, cur) => { return { ...acc, ...cur.initialValues, }; }, {}); + + if (launchConfig.ask_credential_on_launch) { + const defaultCredsWithoutOverrides = []; + + const credentialHasOverride = templateDefaultCred => { + let hasOverride = false; + scheduleCredentials.forEach(scheduleCredential => { + if ( + templateDefaultCred.credential_type === + scheduleCredential.credential_type + ) { + if ( + (!templateDefaultCred.inputs.vault_id && + !scheduleCredential.inputs.vault_id) || + (templateDefaultCred.inputs.vault_id && + scheduleCredential.inputs.vault_id && + templateDefaultCred.inputs.vault_id === + scheduleCredential.inputs.vault_id) + ) { + hasOverride = true; + } + } + }); + + return hasOverride; + }; + + if (resourceDefaultCredentials) { + resourceDefaultCredentials.forEach(defaultCred => { + if (!credentialHasOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + }); + } + + initialValues.credentials = scheduleCredentials.concat( + defaultCredsWithoutOverrides + ); + } + + resetForm({ + values: { + ...initialValues, + ...values, + }, + }); } - resetForm({ - values: { - ...initialValues, - ...values, - }, - }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [launchConfig, surveyConfig, isReady]); diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index b351440ec8..a8158c5c9c 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -6,7 +6,7 @@ import styled from 'styled-components'; import ChipGroup from '../ChipGroup'; const Split = styled(PFSplit)` - margin: 20px 0 5px 0; + margin: 20px 0 5px 0 !important; align-items: baseline; `; diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 4bc2216c22..0848b4287a 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -32,7 +32,13 @@ function Template({ i18n, setBreadcrumb }) { const { me = {} } = useConfig(); const { - result: { isNotifAdmin, template, surveyConfig, launchConfig }, + result: { + isNotifAdmin, + template, + surveyConfig, + launchConfig, + resourceDefaultCredentials, + }, isLoading, error: contentError, request: loadTemplateAndRoles, @@ -40,11 +46,17 @@ function Template({ i18n, setBreadcrumb }) { useCallback(async () => { const [ { data }, + { + data: { results: defaultCredentials }, + }, actions, notifAdminRes, { data: launchConfiguration }, ] = await Promise.all([ JobTemplatesAPI.readDetail(templateId), + JobTemplatesAPI.readCredentials(templateId, { + page_size: 200, + }), JobTemplatesAPI.readTemplateOptions(templateId), OrganizationsAPI.read({ page_size: 1, @@ -52,7 +64,7 @@ function Template({ i18n, setBreadcrumb }) { }), JobTemplatesAPI.readLaunch(templateId), ]); - let surveyConfiguration = null; + let surveyConfiguration = {}; if (data.survey_enabled) { const { data: survey } = await JobTemplatesAPI.readSurvey(templateId); @@ -86,9 +98,10 @@ function Template({ i18n, setBreadcrumb }) { isNotifAdmin: notifAdminRes.data.results.length > 0, surveyConfig: surveyConfiguration, launchConfig: launchConfiguration, + resourceDefaultCredentials: defaultCredentials, }; }, [templateId]), - { isNotifAdmin: false, template: null } + { isNotifAdmin: false, template: null, resourceDefaultCredentials: [] } ); useEffect(() => { @@ -221,6 +234,7 @@ function Template({ i18n, setBreadcrumb }) { loadScheduleOptions={loadScheduleOptions} surveyConfig={surveyConfig} launchConfig={launchConfig} + resourceDefaultCredentials={resourceDefaultCredentials} /> {canSeeNotificationsTab && ( diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index a0aeb1b184..1e2604b54b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -41,6 +41,7 @@ function NodeModalForm({ launchConfig, surveyConfig, isLaunchLoading, + resourceDefaultCredentials, }) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); @@ -69,7 +70,8 @@ function NodeModalForm({ surveyConfig, i18n, values.nodeResource, - askLinkType + askLinkType, + resourceDefaultCredentials ); const handleSaveNode = () => { @@ -229,7 +231,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => { const { request: readLaunchConfigs, error: launchConfigError, - result: { launchConfig, surveyConfig }, + result: { launchConfig, surveyConfig, resourceDefaultCredentials }, isLoading, } = useRequest( useCallback(async () => { @@ -247,6 +249,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => { return { launchConfig: {}, surveyConfig: {}, + resourceDefaultCredentials: [], }; } @@ -267,9 +270,21 @@ const NodeModalInner = ({ i18n, title, ...rest }) => { survey = data; } + let defaultCredentials = []; + + if (launch.ask_credential_on_launch) { + const { + data: { results }, + } = await JobTemplatesAPI.readCredentials(values?.nodeResource?.id, { + page_size: 200, + }); + defaultCredentials = results; + } + return { launchConfig: launch, surveyConfig: survey, + resourceDefaultCredentials: defaultCredentials, }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -319,6 +334,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => { {...rest} launchConfig={launchConfig} surveyConfig={surveyConfig} + resourceDefaultCredentials={resourceDefaultCredentials} isLaunchLoading={isLoading} title={wizardTitle} i18n={i18n} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx index 946718079a..0123f66518 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx @@ -115,6 +115,11 @@ describe('NodeModal', () => { }, }); JobTemplatesAPI.readLaunch.mockResolvedValue({ data: jtLaunchConfig }); + JobTemplatesAPI.readCredentials.mockResolvedValue({ + data: { + results: [], + }, + }); JobTemplatesAPI.readSurvey.mockResolvedValue({ data: { name: '', @@ -239,7 +244,12 @@ describe('NodeModal', () => { nodeToEdit: null, }} > - + ); @@ -254,8 +264,9 @@ describe('NodeModal', () => { test('Can successfully create a new job template node', async () => { act(() => { - wrapper.find('#link-type-always').simulate('click'); + wrapper.find('SelectableCard#link-type-always').simulate('click'); }); + wrapper.update(); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); @@ -271,6 +282,9 @@ describe('NodeModal', () => { wrapper.update(); expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1); + expect(JobTemplatesAPI.readCredentials).toBeCalledWith(1, { + page_size: 200, + }); expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25); wrapper.update(); expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next'); @@ -281,11 +295,6 @@ describe('NodeModal', () => { await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); - - wrapper.update(); - - expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1); - expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25); wrapper.update(); expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Save'); act(() => { @@ -317,7 +326,7 @@ describe('NodeModal', () => { test('Can successfully create a new project sync node', async () => { act(() => { - wrapper.find('#link-type-failure').simulate('click'); + wrapper.find('SelectableCard#link-type-failure').simulate('click'); }); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); @@ -352,7 +361,7 @@ describe('NodeModal', () => { test('Can successfully create a new inventory source sync node', async () => { act(() => { - wrapper.find('#link-type-failure').simulate('click'); + wrapper.find('SelectableCard#link-type-failure').simulate('click'); }); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); @@ -446,7 +455,7 @@ describe('NodeModal', () => { test('Can successfully create a new approval template node', async () => { act(() => { - wrapper.find('#link-type-always').simulate('click'); + wrapper.find('SelectableCard#link-type-always').simulate('click'); }); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); @@ -543,6 +552,7 @@ describe('NodeModal', () => { askLinkType={false} onSave={onSave} title="Edit Node" + resourceDefaultCredentials={[]} /> @@ -629,6 +639,7 @@ describe('NodeModal', () => { askLinkType={false} onSave={onSave} title="Edit Node" + resourceDefaultCredentials={[]} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx index 77a6de336b..3d4f226580 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx @@ -3,7 +3,6 @@ import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; -import { Tooltip } from '@patternfly/react-core'; import { JobTemplatesAPI } from '../../../../../../api'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; import useRequest from '../../../../../../util/useRequest'; @@ -57,56 +56,26 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) { fetchJobTemplates(); }, [fetchJobTemplates]); - const onSelectRow = row => { - if ( - row.project && - row.project !== null && - ((row.inventory && row.inventory !== null) || row.ask_inventory_on_launch) - ) { - onUpdateNodeResource(row); - } - }; - return ( onSelectRow(row)} + onRowClick={row => onUpdateNodeResource(row)} qsConfig={QS_CONFIG} - renderItem={item => { - const isDisabled = - !item.project || - item.project === null || - ((!item.inventory || item.inventory === null) && - !item.ask_inventory_on_launch); - const listItem = ( - onSelectRow(item)} - onDeselect={() => onUpdateNodeResource(null)} - isRadio - /> - ); - return isDisabled ? ( - - {listItem} - - ) : ( - listItem - ); - }} + renderItem={item => ( + onUpdateNodeResource(item)} + onDeselect={() => onUpdateNodeResource(null)} + isRadio + /> + )} renderToolbar={props => } showPageSizeOptions={false} toolbarSearchColumns={[ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx index e59b9a7b48..58606c2e2c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx @@ -61,22 +61,15 @@ describe('JobTemplatesList', () => { ); }); wrapper.update(); + // expect(wrapper.debug()).toBe(false); expect( wrapper.find('CheckboxListItem[name="Test Job Template"]').props() .isSelected ).toBe(true); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template"]').props() - .isDisabled - ).toBe(false); expect( wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() .isSelected ).toBe(false); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() - .isDisabled - ).toBe(false); wrapper .find('CheckboxListItem[name="Test Job Template 2"]') .simulate('click'); @@ -89,71 +82,6 @@ describe('JobTemplatesList', () => { project: 2, }); }); - test('Row disabled when job template missing inventory or project', async () => { - JobTemplatesAPI.read.mockResolvedValueOnce({ - data: { - count: 2, - results: [ - { - id: 1, - name: 'Test Job Template', - type: 'job_template', - url: '/api/v2/job_templates/1', - inventory: 1, - project: null, - ask_inventory_on_launch: false, - }, - { - id: 2, - name: 'Test Job Template 2', - type: 'job_template', - url: '/api/v2/job_templates/2', - inventory: null, - project: 2, - ask_inventory_on_launch: false, - }, - ], - }, - }); - JobTemplatesAPI.readOptions.mockResolvedValue({ - data: { - actions: { - GET: {}, - POST: {}, - }, - related_search_fields: [], - }, - }); - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - wrapper.update(); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template"]').props() - .isSelected - ).toBe(true); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template"]').props() - .isDisabled - ).toBe(true); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() - .isSelected - ).toBe(false); - expect( - wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() - .isDisabled - ).toBe(true); - wrapper - .find('CheckboxListItem[name="Test Job Template 2"]') - .simulate('click'); - expect(onUpdateNodeResource).not.toHaveBeenCalled(); - }); test('Error shown when read() request errors', async () => { JobTemplatesAPI.read.mockRejectedValue(new Error()); JobTemplatesAPI.readOptions.mockResolvedValue({ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx index adcbef80f1..8b653582a9 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t, Trans } from '@lingui/macro'; import styled from 'styled-components'; import { useField } from 'formik'; -import { Form, FormGroup, TextInput } from '@patternfly/react-core'; +import { Alert, Form, FormGroup, TextInput } from '@patternfly/react-core'; import { required } from '../../../../../../util/validators'; import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; @@ -15,6 +15,10 @@ import ProjectsList from './ProjectsList'; import WorkflowJobTemplatesList from './WorkflowJobTemplatesList'; import FormField from '../../../../../../components/FormField'; +const NodeTypeErrorAlert = styled(Alert)` + margin-bottom: 20px; +`; + const TimeoutInput = styled(TextInput)` width: 200px; :not(:first-of-type) { @@ -29,7 +33,9 @@ const TimeoutLabel = styled.p` function NodeTypeStep({ i18n }) { const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType'); - const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource'); + const [nodeResourceField, nodeResourceMeta, nodeResourceHelpers] = useField( + 'nodeResource' + ); const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName'); const [, , approvalDescriptionHelpers] = useField('approvalDescription'); const [timeoutMinutesField, , timeoutMinutesHelpers] = useField( @@ -42,6 +48,13 @@ function NodeTypeStep({ i18n }) { const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; return ( <> + {nodeResourceMeta.error && ( + + )}
{i18n._(t`Node Type`)}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx index 787c30674d..d7c83097e9 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -6,31 +6,62 @@ import StepName from '../../../../../../components/LaunchPrompt/steps/StepName'; const STEP_ID = 'nodeType'; -export default function useNodeTypeStep(i18n) { +export default function useNodeTypeStep(launchConfig, i18n) { const [, meta] = useField('nodeType'); const [approvalNameField] = useField('approvalName'); const [nodeTypeField, ,] = useField('nodeType'); - const [nodeResourceField] = useField('nodeResource'); + const [nodeResourceField, nodeResourceMeta] = useField({ + name: 'nodeResource', + validate: value => { + if ( + value?.type === 'job_template' && + (!value?.project || + value?.project === null || + ((!value?.inventory || value?.inventory === null) && + !value?.ask_inventory_on_launch)) + ) { + return i18n._( + t`Job Templates with a missing inventory or project cannot be selected when creating or editing nodes. Select another template or fix the missing fields to proceed.` + ); + } + return undefined; + }, + }); + + const formError = !!meta.error || !!nodeResourceMeta.error; return { - step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField), + step: getStep( + i18n, + nodeTypeField, + approvalNameField, + nodeResourceField, + formError + ), initialValues: getInitialValues(), isReady: true, contentError: null, - hasError: !!meta.error, + hasError: formError, setTouched: setFieldTouched => { setFieldTouched('nodeType', true, false); }, validate: () => {}, }; } -function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { +function getStep( + i18n, + nodeTypeField, + approvalNameField, + nodeResourceField, + formError +) { const isEnabled = () => { if ( (nodeTypeField.value !== 'workflow_approval_template' && nodeResourceField.value === null) || (nodeTypeField.value === 'workflow_approval_template' && - approvalNameField.value === undefined) + approvalNameField.value === undefined) || + formError ) { return false; } @@ -39,7 +70,7 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { return { id: STEP_ID, name: ( - + {i18n._(t`Node type`)} ), diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js index b6a80d72f2..e882a93329 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -1,5 +1,6 @@ import { useContext, useState, useEffect } from 'react'; import { useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep'; import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep'; import useOtherPromptsStep from '../../../../../components/LaunchPrompt/steps/useOtherPromptsStep'; @@ -29,7 +30,12 @@ function showPreviewStep(nodeType, launchConfig) { ); } -const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => { +const getNodeToEditDefaultValues = ( + launchConfig, + surveyConfig, + nodeToEdit, + resourceDefaultCredentials +) => { const initialValues = { nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', @@ -70,35 +76,34 @@ const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => { } else if (nodeToEdit?.originalNodeCredentials) { const defaultCredsWithoutOverrides = []; - const credentialHasScheduleOverride = templateDefaultCred => { - let credentialHasOverride = false; - nodeToEdit.originalNodeCredentials.forEach(scheduleCred => { + const credentialHasOverride = templateDefaultCred => { + let hasOverride = false; + nodeToEdit.originalNodeCredentials.forEach(nodeCredential => { if ( - templateDefaultCred.credential_type === scheduleCred.credential_type + templateDefaultCred.credential_type === + nodeCredential.credential_type ) { if ( (!templateDefaultCred.vault_id && - !scheduleCred.inputs.vault_id) || + !nodeCredential.inputs.vault_id) || (templateDefaultCred.vault_id && - scheduleCred.inputs.vault_id && - templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) + nodeCredential.inputs.vault_id && + templateDefaultCred.vault_id === nodeCredential.inputs.vault_id) ) { - credentialHasOverride = true; + hasOverride = true; } } }); - return credentialHasOverride; + return hasOverride; }; - if (nodeToEdit?.fullUnifiedJobTemplate?.summary_fields?.credentials) { - nodeToEdit.fullUnifiedJobTemplate.summary_fields.credentials.forEach( - defaultCred => { - if (!credentialHasScheduleOverride(defaultCred)) { - defaultCredsWithoutOverrides.push(defaultCred); - } + if (resourceDefaultCredentials) { + resourceDefaultCredentials.forEach(defaultCred => { + if (!credentialHasOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); } - ); + }); } initialValues.credentials = nodeToEdit.originalNodeCredentials.concat( @@ -179,17 +184,27 @@ export default function useWorkflowNodeSteps( surveyConfig, i18n, resource, - askLinkType + askLinkType, + resourceDefaultCredentials ) { const { nodeToEdit } = useContext(WorkflowStateContext); - const { resetForm, values: formikValues } = useFormikContext(); + const { + resetForm, + values: formikValues, + errors: formikErrors, + } = useFormikContext(); const [visited, setVisited] = useState({}); const steps = [ useRunTypeStep(i18n, askLinkType), - useNodeTypeStep(i18n), + useNodeTypeStep(launchConfig, i18n), useInventoryStep(launchConfig, resource, i18n, visited), - useCredentialsStep(launchConfig, resource, i18n), + useCredentialsStep( + launchConfig, + resource, + resourceDefaultCredentials, + i18n + ), useOtherPromptsStep(launchConfig, resource, i18n), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; @@ -222,7 +237,8 @@ export default function useWorkflowNodeSteps( initialValues = getNodeToEditDefaultValues( launchConfig, surveyConfig, - nodeToEdit + nodeToEdit, + resourceDefaultCredentials ); } else { initialValues = steps.reduce((acc, cur) => { @@ -233,7 +249,23 @@ export default function useWorkflowNodeSteps( }, {}); } + const errors = formikErrors.nodeResource + ? { + nodeResource: formikErrors.nodeResource, + } + : {}; + + if ( + !launchConfig?.ask_credential_on_launch && + launchConfig?.passwords_needed_to_start?.length > 0 + ) { + errors.nodeResource = i18n._( + t`Job Templates with credentials that prompt for passwords cannot be selected when creating or editing nodes` + ); + } + resetForm({ + errors, values: { ...initialValues, nodeResource: formikValues.nodeResource,