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 (
+
+ );
+}
+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}
/>,
]
: []