diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js
index 077a534d27..c9d774e002 100644
--- a/awx/ui_next/src/api/models/Inventories.js
+++ b/awx/ui_next/src/api/models/Inventories.js
@@ -99,6 +99,17 @@ class Inventories extends InstanceGroupsMixin(Base) {
`${this.baseUrl}${inventoryId}/update_inventory_sources/`
);
}
+
+ readAdHocOptions(inventoryId) {
+ return this.http.options(`${this.baseUrl}${inventoryId}/ad_hoc_commands/`);
+ }
+
+ launchAdHocCommands(inventoryId, values) {
+ return this.http.post(
+ `${this.baseUrl}${inventoryId}/ad_hoc_commands/`,
+ values
+ );
+ }
}
export default Inventories;
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
new file mode 100644
index 0000000000..ea28e52652
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
@@ -0,0 +1,144 @@
+import React, { useState, Fragment, useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import PropTypes from 'prop-types';
+
+import useRequest, { useDismissableError } from '../../util/useRequest';
+import AlertModal from '../AlertModal';
+import { CredentialTypesAPI } from '../../api';
+import ErrorDetail from '../ErrorDetail';
+import AdHocCommandsWizard from './AdHocCommandsWizard';
+import ContentLoading from '../ContentLoading';
+import ContentError from '../ContentError';
+
+function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
+ const [isWizardOpen, setIsWizardOpen] = useState(false);
+ const history = useHistory();
+ const verbosityOptions = [
+ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
+ { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) },
+ { value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) },
+ { value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
+ { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
+ ];
+ const {
+ error: fetchError,
+ request: fetchModuleOptions,
+ result: { moduleOptions, credentialTypeId },
+ } = useRequest(
+ useCallback(async () => {
+ const [choices, credId] = await Promise.all([
+ apiModule.readAdHocOptions(itemId),
+ CredentialTypesAPI.read({ namespace: 'ssh' }),
+ ]);
+ const itemObject = (item, index) => {
+ return {
+ key: index,
+ value: item,
+ label: `${item}`,
+ isDisabled: false,
+ };
+ };
+
+ const options = choices.data.actions.GET.module_name.choices.map(
+ (choice, index) => itemObject(choice[0], index)
+ );
+
+ return {
+ moduleOptions: [itemObject('', -1), ...options],
+ credentialTypeId: credId.data.results[0].id,
+ };
+ }, [itemId, apiModule]),
+ { moduleOptions: [] }
+ );
+
+ useEffect(() => {
+ fetchModuleOptions();
+ }, [fetchModuleOptions]);
+
+ const {
+ isloading: isLaunchLoading,
+ error: launchError,
+ request: launchAdHocCommands,
+ } = useRequest(
+ useCallback(
+ async values => {
+ const { data } = await apiModule.launchAdHocCommands(itemId, values);
+ history.push(`/jobs/command/${data.id}/output`);
+ },
+
+ [apiModule, itemId, history]
+ )
+ );
+
+ const { error, dismissError } = useDismissableError(
+ launchError || fetchError
+ );
+
+ const handleSubmit = async values => {
+ const { credential, ...remainingValues } = values;
+ const newCredential = credential[0].id;
+
+ const manipulatedValues = {
+ credential: newCredential,
+ ...remainingValues,
+ };
+ await launchAdHocCommands(manipulatedValues);
+ setIsWizardOpen(false);
+ };
+
+ if (isLaunchLoading) {
+ return ;
+ }
+
+ if (error && isWizardOpen) {
+ return (
+ {
+ dismissError();
+ setIsWizardOpen(false);
+ }}
+ >
+ {launchError ? (
+ <>
+ {i18n._(t`Failed to launch job.`)}
+
+ >
+ ) : (
+
+ )}
+
+ );
+ }
+ return (
+
+ {children({
+ openAdHocCommands: () => setIsWizardOpen(true),
+ })}
+
+ {isWizardOpen && (
+ setIsWizardOpen(false)}
+ onLaunch={handleSubmit}
+ onDismissError={() => dismissError()}
+ />
+ )}
+
+ );
+}
+
+AdHocCommands.propTypes = {
+ children: PropTypes.func.isRequired,
+ adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ itemId: PropTypes.number.isRequired,
+};
+
+export default withI18n()(AdHocCommands);
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
new file mode 100644
index 0000000000..b9582daedc
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
@@ -0,0 +1,347 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../testUtils/enzymeHelpers';
+import { CredentialTypesAPI, InventoriesAPI, CredentialsAPI } from '../../api';
+import AdHocCommands from './AdHocCommands';
+
+jest.mock('../../api/models/CredentialTypes');
+jest.mock('../../api/models/Inventories');
+jest.mock('../../api/models/Credentials');
+
+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' },
+];
+const adHocItems = [
+ {
+ name: 'Inventory 1 Org 0',
+ },
+ { name: 'Inventory 2 Org 0' },
+];
+
+const children = ({ openAdHocCommands }) => (
+ openAdHocCommands()} />
+);
+
+describe(' ', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('mounts successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ expect(wrapper.find('AdHocCommands').length).toBe(1);
+ });
+ test('calls api on Mount', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ expect(wrapper.find('AdHocCommands').length).toBe(1);
+ expect(InventoriesAPI.readAdHocOptions).toBeCalledWith(1);
+ expect(CredentialTypesAPI.read).toBeCalledWith({ namespace: 'ssh' });
+ });
+ test('should open the wizard', async () => {
+ InventoriesAPI.readAdHocOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {
+ module_name: {
+ choices: [
+ ['command', 'command'],
+ ['foo', 'foo'],
+ ],
+ },
+ verbosity: { choices: [[1], [2]] },
+ },
+ },
+ },
+ });
+ CredentialTypesAPI.read.mockResolvedValue({
+ data: { results: [{ id: 1 }] },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ await act(async () => wrapper.find('button').prop('onClick')());
+
+ wrapper.update();
+
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
+ });
+
+ test('should submit properly', async () => {
+ InventoriesAPI.readAdHocOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {
+ module_name: {
+ choices: [
+ ['command', 'command'],
+ ['foo', 'foo'],
+ ],
+ },
+ verbosity: { choices: [[1], [2]] },
+ },
+ },
+ },
+ });
+ CredentialTypesAPI.read.mockResolvedValue({
+ data: { results: [{ id: 1 }] },
+ });
+ CredentialsAPI.read.mockResolvedValue({
+ data: {
+ results: credentials,
+ count: 5,
+ },
+ });
+ InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ await act(async () => wrapper.find('button').prop('onClick')());
+
+ wrapper.update();
+
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
+
+ expect(
+ wrapper
+ .find('WizardNavItem[content="Machine credential"]')
+ .prop('isDisabled')
+ ).toBe(true);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#module_args').simulate('change', {
+ target: { value: 'foo', name: 'module_args' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+
+ wrapper.update();
+
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ await act(async () =>
+ 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-4"]')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ wrapper.update();
+
+ expect(
+ wrapper.find('CheckboxListItem[label="Cred 4"]').prop('isSelected')
+ ).toBe(true);
+
+ await act(async () =>
+ wrapper.find('Button[type="submit"]').prop('onClick')()
+ );
+
+ expect(InventoriesAPI.launchAdHocCommands).toBeCalledWith(1, {
+ module_args: 'foo',
+ diff_mode: false,
+ credential: 4,
+ job_type: 'run',
+ become_enabled: '',
+ extra_vars: '---',
+ forks: 0,
+ limit: 'Inventory 1 Org 0, Inventory 2 Org 0',
+ module_name: 'command',
+ verbosity: 1,
+ });
+
+ wrapper.update();
+
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
+ });
+
+ test('should throw error on submission properly', async () => {
+ InventoriesAPI.launchAdHocCommands.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/inventories/1/ad_hoc_commands',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ InventoriesAPI.readAdHocOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {
+ module_name: {
+ choices: [
+ ['command', 'command'],
+ ['foo', 'foo'],
+ ],
+ },
+ verbosity: { choices: [[1], [2]] },
+ },
+ },
+ },
+ });
+ CredentialTypesAPI.read.mockResolvedValue({
+ data: { results: [{ id: 1 }] },
+ });
+ CredentialsAPI.read.mockResolvedValue({
+ data: {
+ results: credentials,
+ count: 5,
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ await act(async () => wrapper.find('button').prop('onClick')());
+
+ wrapper.update();
+
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
+ expect(
+ wrapper
+ .find('WizardNavItem[content="Machine credential"]')
+ .prop('isDisabled')
+ ).toBe(true);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#module_args').simulate('change', {
+ target: { value: 'foo', name: 'module_args' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+
+ wrapper.update();
+
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+
+ await act(async () =>
+ 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-4"]')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ wrapper.update();
+
+ expect(
+ wrapper.find('CheckboxListItem[label="Cred 4"]').prop('isSelected')
+ ).toBe(true);
+
+ await act(async () =>
+ wrapper.find('Button[type="submit"]').prop('onClick')()
+ );
+
+ waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
+ });
+ test('should open alert modal when error on fetching data', async () => {
+ InventoriesAPI.readAdHocOptions.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'options',
+ url: '/api/v2/inventories/1/',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ await act(async () => wrapper.find('button').prop('onClick')());
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
new file mode 100644
index 0000000000..4a0716f320
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { withFormik, useFormikContext } from 'formik';
+import PropTypes from 'prop-types';
+
+import Wizard from '../Wizard';
+import AdHocCredentialStep from './AdHocCredentialStep';
+import AdHocDetailsStep from './AdHocDetailsStep';
+
+function AdHocCommandsWizard({
+ onLaunch,
+ i18n,
+ moduleOptions,
+ verbosityOptions,
+ onCloseWizard,
+ credentialTypeId,
+}) {
+ const [currentStepId, setCurrentStepId] = useState(1);
+ const [enableLaunch, setEnableLaunch] = useState(false);
+
+ const { values } = useFormikContext();
+
+ const enabledNextOnDetailsStep = () => {
+ if (!values.module_name) {
+ return false;
+ }
+
+ if (values.module_name === 'shell' || values.module_name === 'command') {
+ if (values.module_args) {
+ return true;
+ // eslint-disable-next-line no-else-return
+ } else {
+ return false;
+ }
+ }
+ return undefined; // makes the linter happy;
+ };
+ const steps = [
+ {
+ id: 1,
+ key: 1,
+ name: i18n._(t`Details`),
+ component: (
+
+ ),
+ enableNext: enabledNextOnDetailsStep(),
+ nextButtonText: i18n._(t`Next`),
+ },
+ {
+ id: 2,
+ key: 2,
+ name: i18n._(t`Machine credential`),
+ component: (
+ setEnableLaunch(true)}
+ />
+ ),
+ enableNext: enableLaunch,
+ nextButtonText: i18n._(t`Launch`),
+ canJumpTo: currentStepId >= 2,
+ },
+ ];
+
+ const currentStep = steps.find(step => step.id === currentStepId);
+
+ return (
+ setCurrentStepId(step.id)}
+ onClose={() => onCloseWizard()}
+ onSave={() => {
+ onLaunch(values);
+ }}
+ steps={steps}
+ title={i18n._(t`Run command`)}
+ nextButtonText={currentStep.nextButtonText || undefined}
+ backButtonText={i18n._(t`Back`)}
+ cancelButtonText={i18n._(t`Cancel`)}
+ />
+ );
+}
+
+const FormikApp = withFormik({
+ mapPropsToValues({ adHocItems, verbosityOptions }) {
+ const adHocItemStrings = adHocItems.map(item => item.name).join(', ');
+ return {
+ limit: adHocItemStrings || 'all',
+ credential: [],
+ module_args: '',
+ verbosity: verbosityOptions[0].value,
+ forks: 0,
+ diff_mode: false,
+ become_enabled: '',
+ module_name: '',
+ extra_vars: '---',
+ job_type: 'run',
+ };
+ },
+})(AdHocCommandsWizard);
+
+FormikApp.propTypes = {
+ onLaunch: PropTypes.func.isRequired,
+ moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
+ verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onCloseWizard: PropTypes.func.isRequired,
+ credentialTypeId: PropTypes.number.isRequired,
+};
+export default withI18n()(FormikApp);
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx
new file mode 100644
index 0000000000..ed3ab4ed99
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../testUtils/enzymeHelpers';
+import { CredentialsAPI } from '../../api';
+import AdHocCommandsWizard from './AdHocCommandsWizard';
+
+jest.mock('../../api/models/CredentialTypes');
+jest.mock('../../api/models/Inventories');
+jest.mock('../../api/models/Credentials');
+const verbosityOptions = [
+ { value: '0', key: '0', label: '0 (Normal)' },
+ { value: '1', key: '1', label: '1 (Verbose)' },
+ { value: '2', key: '2', label: '2 (More Verbose)' },
+ { value: '3', key: '3', label: '3 (Debug)' },
+ { value: '4', key: '4', label: '4 (Connection Debug)' },
+];
+const adHocItems = [
+ { name: 'Inventory 1' },
+ { name: 'Inventory 2' },
+ { name: 'inventory 3' },
+];
+describe(' ', () => {
+ let wrapper;
+ const onLaunch = jest.fn();
+ beforeEach(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ credentialTypeId={1}
+ />
+ );
+ });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should mount properly', async () => {
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
+ });
+
+ test('next and nav item should be disabled', async () => {
+ await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
+ expect(
+ wrapper.find('WizardNavItem[content="Details"]').prop('isCurrent')
+ ).toBe(true);
+ expect(
+ wrapper.find('WizardNavItem[content="Details"]').prop('isDisabled')
+ ).toBe(false);
+ expect(
+ wrapper
+ .find('WizardNavItem[content="Machine credential"]')
+ .prop('isDisabled')
+ ).toBe(true);
+ expect(
+ wrapper
+ .find('WizardNavItem[content="Machine credential"]')
+ .prop('isCurrent')
+ ).toBe(false);
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
+ });
+
+ test('next button should become active, and should navigate to the next step', async () => {
+ await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#module_args').simulate('change', {
+ target: { value: 'foo', name: 'module_args' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ await act(async () =>
+ wrapper.find('Button[type="submit"]').prop('onClick')()
+ );
+
+ wrapper.update();
+ });
+ test('launch button should become active', async () => {
+ CredentialsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { id: 1, name: 'Cred 1', url: '' },
+ { id: 2, name: 'Cred2', url: '' },
+ ],
+ count: 2,
+ },
+ });
+ await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#module_args').simulate('change', {
+ target: { value: 'foo', name: 'module_args' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ await act(async () =>
+ wrapper.find('Button[type="submit"]').prop('onClick')()
+ );
+
+ wrapper.update();
+ 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"]')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ wrapper.update();
+
+ expect(
+ wrapper.find('CheckboxListItem[label="Cred 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')()
+ );
+
+ expect(onLaunch).toHaveBeenCalled();
+ });
+
+ test('expect credential step to throw error', async () => {
+ CredentialsAPI.read.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/credentals',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#module_args').simulate('change', {
+ target: { value: 'foo', name: 'module_args' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+
+ 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/AdHocCredentialStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
new file mode 100644
index 0000000000..ade3528ea1
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
@@ -0,0 +1,127 @@
+import React, { useEffect, useCallback } from 'react';
+import { useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import PropTypes from 'prop-types';
+import { useField } from 'formik';
+import { Form, FormGroup } from '@patternfly/react-core';
+import { CredentialsAPI } from '../../api';
+import { FieldTooltip } from '../FormField';
+
+import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
+import useRequest from '../../util/useRequest';
+import ContentError from '../ContentError';
+import ContentLoading from '../ContentLoading';
+import { required } from '../../util/validators';
+import OptionsList from '../OptionsList';
+
+const QS_CONFIG = getQSConfig('credentials', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
+ const history = useHistory();
+ const {
+ error,
+ isLoading,
+ request: fetchCredentials,
+ result: { credentials, credentialCount },
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+
+ const {
+ data: { results, count },
+ } = await CredentialsAPI.read(
+ mergeParams(params, { credential_type: credentialTypeId })
+ );
+
+ return {
+ credentials: results,
+ credentialCount: count,
+ };
+ }, [credentialTypeId, history.location.search]),
+ { credentials: [], credentialCount: 0 }
+ );
+
+ useEffect(() => {
+ fetchCredentials();
+ }, [fetchCredentials]);
+
+ const [credentialField, credentialMeta, credentialHelpers] = useField({
+ name: 'credential',
+ validate: required(null, i18n),
+ });
+ if (error) {
+ return ;
+ }
+ if (isLoading) {
+ return ;
+ }
+ return (
+
+ );
+}
+
+AdHocCredentialStep.propTypes = {
+ credentialTypeId: PropTypes.number.isRequired,
+ onEnableLaunch: PropTypes.func.isRequired,
+};
+export default withI18n()(AdHocCredentialStep);
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.test.jsx
new file mode 100644
index 0000000000..873b792278
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Formik } from 'formik';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../testUtils/enzymeHelpers';
+import { CredentialsAPI } from '../../api';
+import AdHocCredentialStep from './AdHocCredentialStep';
+
+jest.mock('../../api/models/Credentials');
+
+describe(' ', () => {
+ const onEnableLaunch = jest.fn();
+ let wrapper;
+ beforeEach(async () => {
+ CredentialsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { id: 1, name: 'Cred 1', url: 'wwww.google.com' },
+ { id: 2, name: 'Cred2', url: 'wwww.google.com' },
+ ],
+ count: 2,
+ },
+ });
+ 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(CredentialsAPI.read).toHaveBeenCalled();
+ expect(wrapper.find('CheckboxListItem').length).toBe(2);
+ });
+});
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx
new file mode 100644
index 0000000000..4916ba8782
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx
@@ -0,0 +1,288 @@
+/* eslint-disable react/no-unescaped-entities */
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import PropTypes from 'prop-types';
+import { useField } from 'formik';
+import { Form, FormGroup, Switch, Checkbox } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+import { BrandName } from '../../variables';
+import AnsibleSelect from '../AnsibleSelect';
+import FormField, { FieldTooltip } from '../FormField';
+import { VariablesField } from '../CodeMirrorInput';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+ FormCheckboxLayout,
+} from '../FormLayout';
+import { required } from '../../util/validators';
+
+const TooltipWrapper = styled.div`
+ text-align: left;
+`;
+
+// Setting BrandName to a variable here is necessary to get the jest tests
+// passing. Attempting to use BrandName in the template literal results
+// in failing tests.
+const brandName = BrandName;
+
+function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
+ const [module_nameField, module_nameMeta, module_nameHelpers] = useField({
+ name: 'module_name',
+ validate: required(null, i18n),
+ });
+
+ const [variablesField] = useField('extra_vars');
+ const [diff_modeField, , diff_modeHelpers] = useField('diff_mode');
+ const [become_enabledField, , become_enabledHelpers] = useField(
+ 'become_enabled'
+ );
+ const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
+ name: 'verbosity',
+ validate: required(null, i18n),
+ });
+ return (
+
+ }
+ />
+
+ }
+ id="become_enabled"
+ isChecked={become_enabledField.value}
+ onChange={checked => {
+ become_enabledHelpers.setValue(checked);
+ }}
+ />
+
+
+
+
+