diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx index b4523b4bdd..fb8ed89f84 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -12,10 +12,9 @@ import AlertModal from '../AlertModal'; import ErrorDetail from '../ErrorDetail'; import AdHocCommandsWizard from './AdHocCommandsWizard'; import { KebabifiedContext } from '../../contexts/Kebabified'; -import ContentLoading from '../ContentLoading'; import ContentError from '../ContentError'; -function AdHocCommands({ adHocItems, i18n, hasListItems }) { +function AdHocCommands({ adHocItems, i18n, hasListItems, onLaunchLoading }) { const history = useHistory(); const { id } = useParams(); @@ -36,22 +35,29 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) { }, [isKebabified, isWizardOpen, onKebabModalChange]); const { - result: { moduleOptions, credentialTypeId, isAdHocDisabled }, + result: { + moduleOptions, + credentialTypeId, + isAdHocDisabled, + organizationId, + }, request: fetchData, error: fetchError, } = useRequest( useCallback(async () => { - const [options, cred] = await Promise.all([ + const [options, { data }, cred] = await Promise.all([ InventoriesAPI.readAdHocOptions(id), + InventoriesAPI.readDetail(id), CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { moduleOptions: options.data.actions.GET.module_name.choices, credentialTypeId: cred.data.results[0].id, isAdHocDisabled: !options.data.actions.POST, + organizationId: data.organization, }; }, [id]), - { moduleOptions: [], isAdHocDisabled: true } + { moduleOptions: [], isAdHocDisabled: true, organizationId: null } ); useEffect(() => { fetchData(); @@ -76,19 +82,20 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) { ); const handleSubmit = async values => { - const { credential, ...remainingValues } = values; + const { credential, execution_environment, ...remainingValues } = values; const newCredential = credential[0].id; const manipulatedValues = { credential: newCredential, + execution_environment: execution_environment[0]?.id, ...remainingValues, }; await launchAdHocCommands(manipulatedValues); }; - - if (isLaunchLoading) { - return ; - } + useEffect(() => onLaunchLoading(isLaunchLoading), [ + isLaunchLoading, + onLaunchLoading, + ]); if (error && isWizardOpen) { return ( @@ -141,6 +148,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) { {isWizardOpen && ( ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ @@ -51,6 +58,15 @@ describe('', () => { CredentialTypesAPI.read.mockResolvedValue({ data: { count: 1, results: [{ id: 1, name: 'cred' }] }, }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE1 1', url: 'wwww.google.com' }, + { id: 2, name: 'EE2', url: 'wwww.google.com' }, + ], + count: 2, + }, + }); }); let wrapper; afterEach(() => { @@ -61,7 +77,11 @@ describe('', () => { test('mounts successfully', async () => { await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); expect(wrapper.find('AdHocCommands').length).toBe(1); @@ -83,12 +103,26 @@ describe('', () => { }, }, }); + InventoriesAPI.readDetail.mockResolvedValue({ data: { organization: 1 } }); CredentialTypesAPI.read.mockResolvedValue({ data: { results: [{ id: 1 }] }, }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE1 1', url: 'wwww.google.com' }, + { id: 2, name: 'EE2', url: 'wwww.google.com' }, + ], + count: 2, + }, + }); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); await act(async () => @@ -102,15 +136,35 @@ describe('', () => { test('should submit properly', async () => { InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } }); + InventoriesAPI.readDetail.mockResolvedValue({ + data: { organization: 1 }, + }); + CredentialsAPI.read.mockResolvedValue({ data: { results: credentials, count: 5, }, }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE1 1', url: 'wwww.google.com' }, + { id: 2, name: 'EE2', url: 'wwww.google.com' }, + ], + count: 2, + }, + }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); @@ -147,8 +201,27 @@ describe('', () => { wrapper.find('Button[type="submit"]').prop('onClick')() ); await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + // second step of wizard + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { target: { checked: true } }); + }); + + wrapper.update(); + + expect( + wrapper.find('CheckboxListItem[label="EE2"]').prop('isSelected') + ).toBe(true); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + // third step of wizard + await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => { wrapper .find('input[aria-labelledby="check-action-item-4"]') @@ -176,6 +249,7 @@ describe('', () => { limit: 'Inventory 1 Org 0, Inventory 2 Org 0', module_name: 'command', verbosity: 1, + execution_environment: 2, }); }); @@ -202,13 +276,24 @@ describe('', () => { ['foo', 'foo'], ], }, - verbosity: { choices: [[1], [2]] }, + verbosity: { + choices: [[1], [2]], + }, }, }, }, }); + InventoriesAPI.readDetail.mockResolvedValue({ + data: { organization: 1 }, + }); CredentialTypesAPI.read.mockResolvedValue({ - data: { results: [{ id: 1 }] }, + data: { + results: [ + { + id: 1, + }, + ], + }, }); CredentialsAPI.read.mockResolvedValue({ data: { @@ -216,9 +301,33 @@ describe('', () => { count: 5, }, }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'EE1 1', + url: 'wwww.google.com', + }, + { + id: 2, + name: 'EE2', + url: 'wwww.google.com', + }, + ], + count: 2, + }, + }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); @@ -240,7 +349,10 @@ describe('', () => { 'command' ); wrapper.find('input#module_args').simulate('change', { - target: { value: 'foo', name: 'module_args' }, + target: { + value: 'foo', + name: 'module_args', + }, }); wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1); }); @@ -259,10 +371,36 @@ describe('', () => { // second step of wizard + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + + wrapper.update(); + + expect( + wrapper.find('CheckboxListItem[label="EE2"]').prop('isSelected') + ).toBe(true); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + // third step of wizard + await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => { wrapper .find('input[aria-labelledby="check-action-item-4"]') - .simulate('change', { target: { checked: true } }); + .simulate('change', { + target: { + checked: true, + }, + }); }); wrapper.update(); @@ -291,7 +429,11 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -312,7 +454,11 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -335,7 +481,11 @@ describe('', () => { ); await act(async () => { wrapper = mountWithContexts( - + jest.fn()} + /> ); }); await act(async () => wrapper.find('button').prop('onClick')()); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx index 865564ba92..0348878b5d 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { withI18n } from '@lingui/react'; + import { t } from '@lingui/macro'; import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons'; import { Tooltip } from '@patternfly/react-core'; @@ -10,6 +10,7 @@ import styled from 'styled-components'; import Wizard from '../Wizard'; import AdHocCredentialStep from './AdHocCredentialStep'; import AdHocDetailsStep from './AdHocDetailsStep'; +import AdHocExecutionEnvironmentStep from './AdHocExecutionEnvironmentStep'; const AlertText = styled.div` color: var(--pf-global--danger-color--200); @@ -23,11 +24,11 @@ const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` function AdHocCommandsWizard({ onLaunch, - i18n, moduleOptions, verbosityOptions, onCloseWizard, credentialTypeId, + organizationId, }) { const [currentStepId, setCurrentStepId] = useState(1); const [enableLaunch, setEnableLaunch] = useState(false); @@ -57,17 +58,17 @@ function AdHocCommandsWizard({ key: 1, name: hasDetailsStepError ? ( - {i18n._(t`Details`)} + {t`Details`} ) : ( - i18n._(t`Details`) + t`Details` ), component: ( ), enableNext: enabledNextOnDetailsStep(), - nextButtonText: i18n._(t`Next`), + nextButtonText: t`Next`, }, { id: 2, key: 2, - name: i18n._(t`Machine credential`), + name: t`Execution Environment`, + component: ( + + ), + // Removed this line when https://github.com/patternfly/patternfly-react/issues/5729 is fixed + stepNavItemProps: { style: { 'white-space': 'nowrap' } }, + enableNext: true, + nextButtonText: t`Next`, + canJumpTo: currentStepId >= 2, + }, + { + id: 3, + key: 3, + name: t`Machine credential`, component: ( ), enableNext: enableLaunch && Object.values(errors).length === 0, - nextButtonText: i18n._(t`Launch`), + nextButtonText: t`Launch`, canJumpTo: currentStepId >= 2, }, ]; @@ -106,10 +120,10 @@ function AdHocCommandsWizard({ onLaunch(values); }} steps={steps} - title={i18n._(t`Run command`)} + title={t`Run command`} nextButtonText={currentStep.nextButtonText || undefined} - backButtonText={i18n._(t`Back`)} - cancelButtonText={i18n._(t`Cancel`)} + backButtonText={t`Back`} + cancelButtonText={t`Cancel`} /> ); } @@ -128,6 +142,7 @@ const FormikApp = withFormik({ module_name: '', extra_vars: '---', job_type: 'run', + execution_environment: '', }; }, })(AdHocCommandsWizard); @@ -139,4 +154,4 @@ FormikApp.propTypes = { onCloseWizard: PropTypes.func.isRequired, credentialTypeId: PropTypes.number.isRequired, }; -export default withI18n()(FormikApp); +export default FormikApp; diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx index fa2575fd8b..db8d7edd17 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx @@ -4,12 +4,14 @@ import { mountWithContexts, waitForElement, } from '../../../testUtils/enzymeHelpers'; -import { CredentialsAPI } from '../../api'; +import { CredentialsAPI, ExecutionEnvironmentsAPI } from '../../api'; import AdHocCommandsWizard from './AdHocCommandsWizard'; jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Credentials'); +jest.mock('../../api/models/ExecutionEnvironments'); + const verbosityOptions = [ { value: '0', key: '0', label: '0 (Normal)' }, { value: '1', key: '1', label: '1 (Verbose)' }, @@ -39,6 +41,7 @@ describe('', () => { verbosityOptions={verbosityOptions} onCloseWizard={() => {}} credentialTypeId={1} + organizationId={1} /> ); }); @@ -97,6 +100,18 @@ describe('', () => { wrapper.update(); }); test('launch button should become active', async () => { + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE 1', url: '' }, + { id: 2, name: 'EE 2', url: '' }, + ], + count: 2, + }, + }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); CredentialsAPI.read.mockResolvedValue({ data: { results: [ @@ -127,10 +142,40 @@ describe('', () => { ); wrapper.update(); + + // step 2 + + await waitForElement(wrapper, 'OptionsList', el => el.length > 0); + expect(wrapper.find('CheckboxListItem').length).toBe(2); + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { target: { checked: true } }); + }); + + wrapper.update(); + + expect( + wrapper.find('CheckboxListItem[label="EE 1"]').prop('isSelected') + ).toBe(true); + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + // step 3 + await waitForElement(wrapper, 'OptionsList', el => el.length > 0); expect(wrapper.find('CheckboxListItem').length).toBe(2); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); - await act(async () => { wrapper .find('input[aria-labelledby="check-action-item-1"]') @@ -150,8 +195,21 @@ describe('', () => { wrapper.find('Button[type="submit"]').prop('onClick')() ); - expect(onLaunch).toHaveBeenCalled(); + expect(onLaunch).toHaveBeenCalledWith({ + become_enabled: '', + credential: [{ id: 1, name: 'Cred 1', url: '' }], + diff_mode: false, + execution_environment: [{ id: 1, name: 'EE 1', url: '' }], + extra_vars: '---', + forks: 0, + job_type: 'run', + limit: 'Inventory 1, Inventory 2, inventory 3', + module_args: 'foo', + module_name: 'command', + verbosity: 1, + }); }); + test('should show error in navigation bar', async () => { await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); @@ -201,6 +259,12 @@ describe('', () => { wrapper.find('Button[type="submit"]').prop('onClick')() ); + wrapper.update(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); expect(wrapper.find('ContentError').length).toBe(1); }); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnironmentStep.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnironmentStep.test.jsx new file mode 100644 index 0000000000..539ec5874e --- /dev/null +++ b/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnironmentStep.test.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { ExecutionEnvironmentsAPI } from '../../api'; +import AdHocExecutionEnvironmentStep from './AdHocExecutionEnvironmentStep'; + +jest.mock('../../api/models/ExecutionEnvironments'); + +describe('', () => { + let wrapper; + beforeEach(async () => { + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE1 1', url: 'wwww.google.com' }, + { id: 2, name: 'EE2', url: 'wwww.google.com' }, + ], + count: 2, + }, + }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should mount properly', async () => { + await waitForElement(wrapper, 'OptionsList', el => el.length > 0); + }); + + test('should call api', async () => { + await waitForElement(wrapper, 'OptionsList', el => el.length > 0); + expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalled(); + expect(wrapper.find('CheckboxListItem').length).toBe(2); + }); +}); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnvironmentStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnvironmentStep.jsx new file mode 100644 index 0000000000..4c8c6ec3ae --- /dev/null +++ b/awx/ui_next/src/components/AdHocCommands/AdHocExecutionEnvironmentStep.jsx @@ -0,0 +1,141 @@ +import React, { useEffect, useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { Form, FormGroup } from '@patternfly/react-core'; +import { ExecutionEnvironmentsAPI } from '../../api'; +import Popover from '../Popover'; + +import { parseQueryString, getQSConfig, mergeParams } from '../../util/qs'; +import useRequest from '../../util/useRequest'; +import ContentError from '../ContentError'; +import ContentLoading from '../ContentLoading'; +import OptionsList from '../OptionsList'; + +const QS_CONFIG = getQSConfig('execution_environments', { + page: 1, + page_size: 5, + order_by: 'name', +}); +function AdHocExecutionEnvironmentStep({ organizationId }) { + const history = useHistory(); + const [executionEnvironmentField, , executionEnvironmentHelpers] = useField( + 'execution_environment' + ); + const { + error, + isLoading, + request: fetchExecutionEnvironments, + result: { + executionEnvironments, + executionEnvironmentsCount, + relatedSearchableKeys, + searchableKeys, + }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const globallyAvailableParams = { or__organization__isnull: 'True' }; + const organizationIdParams = organizationId + ? { or__organization__id: organizationId } + : {}; + + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + ExecutionEnvironmentsAPI.read( + mergeParams(params, { + ...globallyAvailableParams, + ...organizationIdParams, + }) + ), + ExecutionEnvironmentsAPI.readOptions(), + ]); + return { + executionEnvironments: results, + executionEnvironmentsCount: count, + relatedSearchableKeys: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + actionsResponse.data.actions?.GET || {} + ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + }; + }, [history.location.search, organizationId]), + { + executionEnvironments: [], + executionEnvironmentsCount: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchExecutionEnvironments(); + }, [fetchExecutionEnvironments]); + + if (error) { + return ; + } + if (isLoading) { + return ; + } + + return ( +
+ + } + > + { + executionEnvironmentHelpers.setValue([value]); + }} + deselectItem={() => { + executionEnvironmentHelpers.setValue([]); + }} + /> + +
+ ); +} +export default AdHocExecutionEnvironmentStep; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 1215413c3d..682883f907 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -28,6 +28,7 @@ const QS_CONFIG = getQSConfig('host', { }); function InventoryGroupHostList({ i18n }) { + const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const { id: inventoryId, groupId } = useParams(); const location = useLocation(); @@ -172,7 +173,9 @@ function InventoryGroupHostList({ i18n }) { <> 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, 0} + onLaunchLoading={setIsAdHocLaunchLoading} />, ] : []