mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 01:17:37 -02:30
Adds Ad Hoc Commands Wizard
This commit is contained in:
@@ -99,6 +99,17 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
`${this.baseUrl}${inventoryId}/update_inventory_sources/`
|
`${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;
|
export default Inventories;
|
||||||
|
|||||||
145
awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
Normal file
145
awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
Normal file
@@ -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 <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && isWizardOpen) {
|
||||||
|
return (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={error}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={() => {
|
||||||
|
dismissError();
|
||||||
|
setIsWizardOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentError error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{children({
|
||||||
|
openAdHocCommands: () => setIsWizardOpen(true),
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isWizardOpen && (
|
||||||
|
<AdHocCommandsForm
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
credentialTypeId={credentialTypeId}
|
||||||
|
onCloseWizard={() => setIsWizardOpen(false)}
|
||||||
|
onLaunch={handleSubmit}
|
||||||
|
error={error}
|
||||||
|
onDismissError={() => dismissError()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{launchError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={error}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={dismissError}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to launch job.`)}
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(AdHocCommands);
|
||||||
339
awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
Normal file
339
awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
Normal file
@@ -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 }) => (
|
||||||
|
<button type="submit" onClick={() => openAdHocCommands()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<AdHocCOmmands />', () => {
|
||||||
|
let wrapper;
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mounts successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId={1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('AdHocCommands').length).toBe(1);
|
||||||
|
});
|
||||||
|
test('calls api on Mount', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId={1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId={1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId={1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId="a"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<AdHocCommands
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
itemId={1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdHocCommands>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.find('button').prop('onClick')();
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: (
|
||||||
|
<DetailsStep
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
onLimitChange={value => 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: (
|
||||||
|
<AdHocCredentialStep
|
||||||
|
credentialTypeId={credentialTypeId}
|
||||||
|
onEnableLaunch={() => 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 (
|
||||||
|
<Wizard
|
||||||
|
style={{ overflow: 'scroll' }}
|
||||||
|
isOpen
|
||||||
|
onNext={step => 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);
|
||||||
@@ -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('<AdHocCommandsWizard/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
const onLaunch = jest.fn();
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<AdHocCommandsWizard
|
||||||
|
adHocItems={adHocItems}
|
||||||
|
onLaunch={onLaunch}
|
||||||
|
moduleOptions={[]}
|
||||||
|
verbosityOptions={[]}
|
||||||
|
onCloseWizard={() => {}}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
Normal file
114
awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
Normal file
@@ -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 <ContentError error={error} />;
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
return <ContentLoading error={error} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="credential"
|
||||||
|
label={i18n._(t`Machine Credential`)}
|
||||||
|
isRequired
|
||||||
|
validated={
|
||||||
|
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'
|
||||||
|
}
|
||||||
|
helperTextInvalid={credentialMeta.error}
|
||||||
|
>
|
||||||
|
<OptionsList
|
||||||
|
value={credentialField.value || []}
|
||||||
|
options={credentials}
|
||||||
|
optionCount={credentialCount}
|
||||||
|
header={i18n._(t`Machine Credential`)}
|
||||||
|
readOnly
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
searchColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created By (Username)`),
|
||||||
|
key: 'created_by__username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Modified By (Username)`),
|
||||||
|
key: 'modified_by__username',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sortColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
name="credential"
|
||||||
|
selectItem={value => {
|
||||||
|
credentialHelpers.setValue([value]);
|
||||||
|
onEnableLaunch();
|
||||||
|
}}
|
||||||
|
deselectItem={() => {
|
||||||
|
credentialHelpers.setValue([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(AdHocCredentialStep);
|
||||||
@@ -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('<AdHocCredentialStep />', () => {
|
||||||
|
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(
|
||||||
|
<Formik>
|
||||||
|
<AdHocCredentialStep
|
||||||
|
credentialTypeId={1}
|
||||||
|
onEnableLaunch={onEnableLaunch}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
300
awx/ui_next/src/components/AdHocCommands/DetailsStep.jsx
Normal file
300
awx/ui_next/src/components/AdHocCommands/DetailsStep.jsx
Normal file
@@ -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 (
|
||||||
|
<Form>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="module"
|
||||||
|
label={i18n._(t`Module`)}
|
||||||
|
isRequired
|
||||||
|
helperTextInvalid={moduleMeta.error}
|
||||||
|
validated={
|
||||||
|
!moduleMeta.touched || !moduleMeta.error ? 'default' : 'error'
|
||||||
|
}
|
||||||
|
labelIcon={
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`These are the modules that AWX supports running commands against.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...moduleField}
|
||||||
|
isValid={!moduleMeta.touched || !moduleMeta.error}
|
||||||
|
id="module"
|
||||||
|
data={moduleOptions || []}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
moduleHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormField
|
||||||
|
id="arguments"
|
||||||
|
name="arguments"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Arguments`)}
|
||||||
|
isRequired
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`These arguments are used with the specified module.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="verbosity"
|
||||||
|
label={i18n._(t`Verbosity`)}
|
||||||
|
isRequired
|
||||||
|
validated={
|
||||||
|
!verbosityMeta.touched || !verbosityMeta.error
|
||||||
|
? 'default'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
helperTextInvalid={verbosityMeta.error}
|
||||||
|
labelIcon={
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`These are the verbosity levels for standard out of the command run that are supported.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...verbosityField}
|
||||||
|
isValid={!verbosityMeta.touched || !verbosityMeta.error}
|
||||||
|
id="verbosity"
|
||||||
|
data={verbosityOptions || []}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
verbosityHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
label={i18n._(t`Limit`)}
|
||||||
|
labelIcon={
|
||||||
|
<FieldTooltip
|
||||||
|
content={
|
||||||
|
<span>
|
||||||
|
{i18n._(
|
||||||
|
t`The pattern used to target hosts in the inventory. Leaving the field blank, all, and * will all target all hosts in the inventory. You can find more information about Ansible's host patterns`
|
||||||
|
)}{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.ansible.com/ansible/latest/user_guide/intro_patterns.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{i18n._(`here`)}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText>
|
||||||
|
{limitField.value.map((item, index) => (
|
||||||
|
<Label
|
||||||
|
onClose={() => {
|
||||||
|
limitField.value.splice(index, 1);
|
||||||
|
limitHelpers.setValue(limitField.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</InputGroupText>
|
||||||
|
<TextInput
|
||||||
|
id="limit"
|
||||||
|
name="limit"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Limit`)}
|
||||||
|
value={limitValue}
|
||||||
|
isRequired
|
||||||
|
onChange={value => {
|
||||||
|
onLimitChange(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</FormGroup>
|
||||||
|
<FormField
|
||||||
|
id="template-forks"
|
||||||
|
name="forks"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
label={i18n._(t`Forks`)}
|
||||||
|
tooltip={
|
||||||
|
<span>
|
||||||
|
{i18n._(
|
||||||
|
t`The number of parallel or simultaneous processes to use while executing the playbook. Inputting no value will use the default value from the `
|
||||||
|
)}{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.ansible.com/ansible/latest/installation_guide/intro_configuration.html#the-ansible-configuration-file"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{i18n._(t`ansible configuration file.`)}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<FormGroup
|
||||||
|
label={i18n._(t`Show changes`)}
|
||||||
|
labelIcon={
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible’s --diff mode.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
css="display: inline-flex;"
|
||||||
|
id="changes"
|
||||||
|
label={i18n._(t`On`)}
|
||||||
|
labelOff={i18n._(t`Off`)}
|
||||||
|
isChecked={changesField.value}
|
||||||
|
onChange={() => {
|
||||||
|
changesHelpers.setValue(!changesField.value);
|
||||||
|
}}
|
||||||
|
aria-label={i18n._(t`toggle changes`)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
name={i18n._(t`enable privilege escalation`)}
|
||||||
|
fieldId="escalation"
|
||||||
|
>
|
||||||
|
<FormCheckboxLayout>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={i18n._(t`Enable privilege escalation`)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{i18n._(t`Enable privilege escalation`)}
|
||||||
|
|
||||||
|
<FieldTooltip
|
||||||
|
content={
|
||||||
|
<p>
|
||||||
|
{i18n._(t`Enables creation of a provisioning
|
||||||
|
callback URL. Using the URL a host can contact BRAND_NAME
|
||||||
|
and request a configuration update using this job
|
||||||
|
template`)}
|
||||||
|
|
||||||
|
<code>--{i18n._(t`become`)} </code>
|
||||||
|
{i18n._(t`option to the`)}
|
||||||
|
<code>{i18n._(t`ansible`)} </code>
|
||||||
|
{i18n._(t`command`)}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
id="escalation"
|
||||||
|
isChecked={escalationField.value}
|
||||||
|
onChange={checked => {
|
||||||
|
escalationHelpers.setValue(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormCheckboxLayout>
|
||||||
|
</FormGroup>
|
||||||
|
</FormColumnLayout>
|
||||||
|
|
||||||
|
<VariablesField
|
||||||
|
css="margin: 20px 0"
|
||||||
|
id="extra_vars"
|
||||||
|
name="extra_vars"
|
||||||
|
value={JSON.stringify(variablesField.value)}
|
||||||
|
rows={4}
|
||||||
|
labelIcon
|
||||||
|
tooltip={
|
||||||
|
<TooltipWrapper>
|
||||||
|
<p>
|
||||||
|
{i18n._(
|
||||||
|
t`Pass extra command line changes. There are two ansible command line parameters: `
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<code>-e</code>, <code>--extra-vars </code>
|
||||||
|
<br />
|
||||||
|
{i18n._(t`Provide key/value pairs using either
|
||||||
|
YAML or JSON.`)}
|
||||||
|
</p>
|
||||||
|
JSON:
|
||||||
|
<br />
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{'{'}
|
||||||
|
{'\n '}"somevar": "somevalue",
|
||||||
|
{'\n '}"password": "magic"
|
||||||
|
{'\n'}
|
||||||
|
{'}'}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
YAML:
|
||||||
|
<br />
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
---
|
||||||
|
{'\n'}somevar: somevalue
|
||||||
|
{'\n'}password: magic
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipWrapper>
|
||||||
|
}
|
||||||
|
label={i18n._(t`Extra variables`)}
|
||||||
|
/>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
</FormColumnLayout>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(CredentialStep);
|
||||||
150
awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx
Normal file
150
awx/ui_next/src/components/AdHocCommands/DetailsStep.test.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import DetailsStep from './DetailsStep';
|
||||||
|
|
||||||
|
jest.mock('../../api/models/Credentials');
|
||||||
|
|
||||||
|
const verbosityOptions = [
|
||||||
|
{ key: -1, value: '', label: '', isDisabled: false },
|
||||||
|
{ key: 0, value: 0, label: '0', isDisabled: false },
|
||||||
|
{ key: 1, value: 1, label: '1', isDisabled: false },
|
||||||
|
];
|
||||||
|
const moduleOptions = [
|
||||||
|
{ key: -1, value: '', label: '', isDisabled: false },
|
||||||
|
{ key: 0, value: 'command', label: 'command', isDisabled: false },
|
||||||
|
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
|
||||||
|
];
|
||||||
|
const onLimitChange = jest.fn();
|
||||||
|
const limitValue = '';
|
||||||
|
const initialValues = {
|
||||||
|
limit: ['Inventory 1', 'inventory 2'],
|
||||||
|
credential: [],
|
||||||
|
module_args: '',
|
||||||
|
arguments: '',
|
||||||
|
verbosity: '',
|
||||||
|
forks: 0,
|
||||||
|
changes: false,
|
||||||
|
escalation: false,
|
||||||
|
extra_vars: '---',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<DetailsStep />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mount properly', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Formik initialValues={initialValues}>
|
||||||
|
<DetailsStep
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
limitValue={limitValue}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show all the fields', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Formik initialValues={initialValues}>
|
||||||
|
<DetailsStep
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
limitValue={limitValue}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('FormGroup[label="Module"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormField[name="arguments"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Verbosity"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Limit"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormField[name="forks"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="Show changes"]').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[name="enable privilege escalation"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(wrapper.find('VariablesField').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shold update form values', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Formik initialValues={initialValues}>
|
||||||
|
<DetailsStep
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
limitValue={limitValue}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
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.find('TextInputBase[name="forks"]').simulate('change', {
|
||||||
|
target: { value: 10, name: 'forks' },
|
||||||
|
});
|
||||||
|
wrapper.find('Switch').invoke('onChange')();
|
||||||
|
wrapper
|
||||||
|
.find('Checkbox[aria-label="Enable privilege escalation"]')
|
||||||
|
.invoke('onChange')(true, {
|
||||||
|
currentTarget: { value: true, type: 'change', checked: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AnsibleSelect[name="module_args"]').prop('value')
|
||||||
|
).toBe('command');
|
||||||
|
expect(wrapper.find('input#arguments').prop('value')).toBe('foo');
|
||||||
|
expect(wrapper.find('AnsibleSelect[name="verbosity"]').prop('value')).toBe(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(wrapper.find('TextInputBase[name="forks"]').prop('value')).toBe(10);
|
||||||
|
expect(wrapper.find('TextInputBase[label="Limit"]').prop('value')).toBe('');
|
||||||
|
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Checkbox[aria-label="Enable privilege escalation"]')
|
||||||
|
.prop('isChecked')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mount with proper limit value', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Formik initialValues={initialValues}>
|
||||||
|
<DetailsStep
|
||||||
|
verbosityOptions={verbosityOptions}
|
||||||
|
moduleOptions={moduleOptions}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
limitValue="foo value"
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('TextInputBase[label="Limit"]').prop('value')).toBe(
|
||||||
|
'foo value'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/components/AdHocCommands/index.js
Normal file
1
awx/ui_next/src/components/AdHocCommands/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './AdHocCommands';
|
||||||
@@ -13,8 +13,10 @@ import DataListToolbar from '../../../components/DataListToolbar';
|
|||||||
import PaginatedDataList, {
|
import PaginatedDataList, {
|
||||||
ToolbarAddButton,
|
ToolbarAddButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
|
|
||||||
import InventoryGroupItem from './InventoryGroupItem';
|
import InventoryGroupItem from './InventoryGroupItem';
|
||||||
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
||||||
|
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('group', {
|
const QS_CONFIG = getQSConfig('group', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -225,6 +227,34 @@ function InventoryGroupsList({ i18n }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
|
[
|
||||||
|
<Tooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single group or a selection of multiple groups.`
|
||||||
|
)}
|
||||||
|
position="top"
|
||||||
|
key="adhoc"
|
||||||
|
>
|
||||||
|
<AdHocCommandsButton
|
||||||
|
adHocItems={selected}
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
itemId={inventoryId}
|
||||||
|
>
|
||||||
|
{({ openAdHocCommands }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Run command`)}
|
||||||
|
onClick={openAdHocCommands}
|
||||||
|
isDisabled={
|
||||||
|
selected.length === 0 || selected.some(cannotDelete)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{i18n._(t`Run command`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AdHocCommandsButton>
|
||||||
|
</Tooltip>,
|
||||||
|
],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user