From e6ae171f4b60106b7618244fcf6be5be5e3b50e2 Mon Sep 17 00:00:00 2001
From: Alex Corey
Date: Thu, 13 Aug 2020 15:38:18 -0400
Subject: [PATCH] Adds Ad Hoc Commands Wizard
---
awx/ui_next/src/api/models/Inventories.js | 11 +
.../AdHocCommands/AdHocCommands.jsx | 145 ++++++++
.../AdHocCommands/AdHocCommands.test.jsx | 339 ++++++++++++++++++
.../AdHocCommands/AdHocCommandsWizard.jsx | 95 +++++
.../AdHocCommandsWizard.test.jsx | 176 +++++++++
.../AdHocCommands/AdHocCredentialStep.jsx | 114 ++++++
.../AdHocCredentialStep.test.jsx | 51 +++
.../components/AdHocCommands/DetailsStep.jsx | 300 ++++++++++++++++
.../AdHocCommands/DetailsStep.test.jsx | 150 ++++++++
.../src/components/AdHocCommands/index.js | 1 +
.../InventoryGroups/InventoryGroupsList.jsx | 30 ++
11 files changed, 1412 insertions(+)
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.test.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/DetailsStep.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx
create mode 100644 awx/ui_next/src/components/AdHocCommands/index.js
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..0fa1990a6d
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
@@ -0,0 +1,145 @@
+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 useRequest, { useDismissableError } from '../../util/useRequest';
+import AlertModal from '../AlertModal';
+import { CredentialTypesAPI } from '../../api';
+import ErrorDetail from '../ErrorDetail';
+import AdHocCommandsForm 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 {
+ error: fetchError,
+ request: fetchModuleOptions,
+ result: { moduleOptions, verbosityOptions, 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)
+ );
+
+ const verbosityItems = choices.data.actions.GET.verbosity.choices.map(
+ (choice, index) => itemObject(choice[0], index)
+ );
+
+ return {
+ moduleOptions: [itemObject('', -1), ...options],
+ verbosityOptions: [itemObject('', -1), ...verbosityItems],
+ 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/${data.module_name}/${data.id}/output`);
+ },
+ [apiModule, itemId, history]
+ )
+ );
+
+ const { error, dismissError } = useDismissableError(
+ launchError || fetchError
+ );
+
+ const handleSubmit = async (values, limitTypedValue) => {
+ const { credential, limit, ...remainingValues } = values;
+ const newCredential = credential[0].id;
+ if (limitTypedValue) {
+ values.limit = limit.concat(limitTypedValue);
+ }
+ const stringifyLimit = values.limit.join(', ').trim();
+
+ const manipulatedValues = {
+ limit: stringifyLimit[0],
+ credential: newCredential,
+ ...remainingValues,
+ };
+ await launchAdHocCommands(manipulatedValues);
+ setIsWizardOpen(false);
+ };
+
+ if (isLaunchLoading) {
+ return ;
+ }
+
+ if (error && isWizardOpen) {
+ return (
+ {
+ dismissError();
+ setIsWizardOpen(false);
+ }}
+ >
+
+
+ );
+ }
+ return (
+
+ {children({
+ openAdHocCommands: () => setIsWizardOpen(true),
+ })}
+
+ {isWizardOpen && (
+ setIsWizardOpen(false)}
+ onLaunch={handleSubmit}
+ error={error}
+ onDismissError={() => dismissError()}
+ />
+ )}
+ {launchError && (
+
+ {i18n._(t`Failed to launch job.`)}
+
+
+ )}
+
+ );
+}
+
+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..dadd88fc86
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
@@ -0,0 +1,339 @@
+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}
+
+ );
+ });
+ 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,
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ {children}
+
+ );
+ });
+ 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);
+
+ act(() => {
+ wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#arguments').simulate('change', {
+ target: { value: 'foo', name: 'arguments' },
+ });
+ 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, {
+ arguments: 'foo',
+ changes: false,
+ credential: 4,
+ escalation: false,
+ extra_vars: '---',
+ forks: 0,
+ limit: 'I',
+ module_args: '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}
+
+ );
+ });
+ 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);
+
+ act(() => {
+ wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#arguments').simulate('change', {
+ target: { value: 'foo', name: 'arguments' },
+ });
+ 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')()
+ );
+
+ wrapper.update();
+
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+ 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}
+
+ );
+ });
+ 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..1fb7d30253
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
@@ -0,0 +1,95 @@
+import React, { useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { withFormik, useFormikContext } from 'formik';
+
+import Wizard from '../Wizard';
+import AdHocCredentialStep from './AdHocCredentialStep';
+import DetailsStep from './DetailsStep';
+
+function AdHocCommandsWizard({
+ onLaunch,
+ i18n,
+ moduleOptions,
+ verbosityOptions,
+ onCloseWizard,
+ credentialTypeId,
+}) {
+ const [currentStepId, setCurrentStepId] = useState(1);
+ const [limitTypedValue, setLimitTypedValue] = useState('');
+ const [enableLaunch, setEnableLaunch] = useState(false);
+
+ const { values } = useFormikContext();
+
+ const steps = [
+ {
+ id: 1,
+ key: 1,
+ name: i18n._(t`Details`),
+ component: (
+ setLimitTypedValue(value)}
+ limitValue={limitTypedValue}
+ />
+ ),
+ enableNext: values.module_args && values.arguments && values.verbosity,
+ 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);
+
+ const submit = () => {
+ onLaunch(values, limitTypedValue);
+ };
+
+ return (
+ setCurrentStepId(step.id)}
+ onClose={() => onCloseWizard()}
+ onSave={submit}
+ steps={steps}
+ title={i18n._(t`Ad Hoc Commands`)}
+ nextButtonText={currentStep.nextButtonText || undefined}
+ backButtonText={i18n._(t`Back`)}
+ cancelButtonText={i18n._(t`Cancel`)}
+ />
+ );
+}
+
+const FormikApp = withFormik({
+ mapPropsToValues({ adHocItems }) {
+ const adHocItemStrings = adHocItems.map(item => item.name);
+ return {
+ limit: adHocItemStrings || [],
+ credential: [],
+ module_args: '',
+ arguments: '',
+ verbosity: '',
+ forks: 0,
+ changes: false,
+ escalation: false,
+ extra_vars: '---',
+ };
+ },
+})(AdHocCommandsWizard);
+
+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..54dc1c9daf
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.test.jsx
@@ -0,0 +1,176 @@
+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 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 () => {
+ // wrapper.update();
+ expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
+ });
+
+ test('next and nav item should be disabled', async () => {
+ // wrapper.update();
+ 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);
+
+ act(() => {
+ wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#arguments').simulate('change', {
+ target: { value: 'foo', name: 'arguments' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ 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' },
+ { id: 2, name: 'Cred2' },
+ ],
+ count: 2,
+ },
+ });
+ await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
+
+ act(() => {
+ wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#arguments').simulate('change', {
+ target: { value: 'foo', name: 'arguments' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ 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
+ );
+ 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);
+
+ act(() => {
+ wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
+ {},
+ 'command'
+ );
+ wrapper.find('input#arguments').simulate('change', {
+ target: { value: 'foo', name: 'arguments' },
+ });
+ wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
+ });
+ wrapper.update();
+ expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
+ false
+ );
+ wrapper.find('Button[type="submit"]').prop('onClick')();
+
+ wrapper.update();
+ expect(wrapper.find('ContentLoading').length).toBe(1);
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ 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..3c1c81d147
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
@@ -0,0 +1,114 @@
+import React, { useEffect, useCallback } from 'react';
+import { useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField } from 'formik';
+import { Form, FormGroup } from '@patternfly/react-core';
+import { CredentialsAPI } from '../../api';
+
+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 (
+
+ );
+}
+
+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/DetailsStep.jsx b/awx/ui_next/src/components/AdHocCommands/DetailsStep.jsx
new file mode 100644
index 0000000000..5bf0904e9e
--- /dev/null
+++ b/awx/ui_next/src/components/AdHocCommands/DetailsStep.jsx
@@ -0,0 +1,300 @@
+/* eslint-disable react/no-unescaped-entities */
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+
+import { useField } from 'formik';
+import {
+ Form,
+ FormGroup,
+ InputGroup,
+ TextInput,
+ Label,
+ InputGroupText,
+ Switch,
+ Checkbox,
+} from '@patternfly/react-core';
+import styled from 'styled-components';
+
+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;
+`;
+
+function CredentialStep({
+ i18n,
+ verbosityOptions,
+ moduleOptions,
+ onLimitChange,
+ limitValue,
+}) {
+ const [moduleField, moduleMeta, moduleHelpers] = useField({
+ name: 'module_args',
+ validate: required(null, i18n),
+ });
+ const [limitField, , limitHelpers] = useField('limit');
+ const [variablesField] = useField('extra_vars');
+ const [changesField, , changesHelpers] = useField('changes');
+ const [escalationField, , escalationHelpers] = useField('escalation');
+ const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
+ name: 'verbosity',
+ validate: required(null, i18n),
+ });
+
+ return (
+
+ }
+ />
+
+ }
+ id="escalation"
+ isChecked={escalationField.value}
+ onChange={checked => {
+ escalationHelpers.setValue(checked);
+ }}
+ />
+
+
+
+
+