mirror of
https://github.com/ansible/awx.git
synced 2026-01-21 14:38:00 -03:30
Merge pull request #7905 from AlexSCorey/6603-AdHocCommands
Add Ad Hoc Commands Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
dff7667532
@ -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;
|
||||
|
||||
144
awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
Normal file
144
awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
Normal file
@ -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 <ContentLoading />;
|
||||
}
|
||||
|
||||
if (error && isWizardOpen) {
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => {
|
||||
dismissError();
|
||||
setIsWizardOpen(false);
|
||||
}}
|
||||
>
|
||||
{launchError ? (
|
||||
<>
|
||||
{i18n._(t`Failed to launch job.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</>
|
||||
) : (
|
||||
<ContentError error={error} />
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{children({
|
||||
openAdHocCommands: () => setIsWizardOpen(true),
|
||||
})}
|
||||
|
||||
{isWizardOpen && (
|
||||
<AdHocCommandsWizard
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
verbosityOptions={verbosityOptions}
|
||||
credentialTypeId={credentialTypeId}
|
||||
onCloseWizard={() => setIsWizardOpen(false)}
|
||||
onLaunch={handleSubmit}
|
||||
onDismissError={() => dismissError()}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
AdHocCommands.propTypes = {
|
||||
children: PropTypes.func.isRequired,
|
||||
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(AdHocCommands);
|
||||
347
awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
Normal file
347
awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx
Normal file
@ -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 }) => (
|
||||
<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}
|
||||
credentialTypeId={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}
|
||||
credentialTypeId={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}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
114
awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
Normal file
114
awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx
Normal file
@ -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: (
|
||||
<AdHocDetailsStep
|
||||
moduleOptions={moduleOptions}
|
||||
verbosityOptions={verbosityOptions}
|
||||
/>
|
||||
),
|
||||
enableNext: enabledNextOnDetailsStep(),
|
||||
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);
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
style={{ overflow: 'scroll' }}
|
||||
isOpen
|
||||
onNext={step => 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);
|
||||
@ -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('<AdHocCommandsWizard/>', () => {
|
||||
let wrapper;
|
||||
const onLaunch = jest.fn();
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommandsWizard
|
||||
adHocItems={adHocItems}
|
||||
onLaunch={onLaunch}
|
||||
moduleOptions={[]}
|
||||
verbosityOptions={verbosityOptions}
|
||||
onCloseWizard={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
127
awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
Normal file
127
awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
Normal file
@ -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 <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}
|
||||
labelIcon={
|
||||
<FieldTooltip
|
||||
content={i18n._(
|
||||
t`Select the credential you want to use when accessing the remote hosts to run the command. Choose the credential containing the username and SSH key or password that Ansible will need to log into the remote hosts.`
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
AdHocCredentialStep.propTypes = {
|
||||
credentialTypeId: PropTypes.number.isRequired,
|
||||
onEnableLaunch: PropTypes.func.isRequired,
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
288
awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx
Normal file
288
awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx
Normal file
@ -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 (
|
||||
<Form>
|
||||
<FormColumnLayout>
|
||||
<FormFullWidthLayout>
|
||||
<FormGroup
|
||||
fieldId="module_name"
|
||||
label={i18n._(t`Module`)}
|
||||
isRequired
|
||||
helperTextInvalid={module_nameMeta.error}
|
||||
validated={
|
||||
!module_nameMeta.touched || !module_nameMeta.error
|
||||
? 'default'
|
||||
: 'error'
|
||||
}
|
||||
labelIcon={
|
||||
<FieldTooltip
|
||||
content={i18n._(
|
||||
t`These are the modules that ${brandName} supports running commands against.`
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AnsibleSelect
|
||||
{...module_nameField}
|
||||
isValid={!module_nameMeta.touched || !module_nameMeta.error}
|
||||
id="module_name"
|
||||
data={moduleOptions || []}
|
||||
onChange={(event, value) => {
|
||||
module_nameHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormField
|
||||
id="module_args"
|
||||
name="module_args"
|
||||
type="text"
|
||||
label={i18n._(t`Arguments`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired={
|
||||
module_nameField.value === 'command' ||
|
||||
module_nameField.value === 'shell'
|
||||
}
|
||||
tooltip={
|
||||
module_nameField.value ? (
|
||||
<>
|
||||
{i18n._(
|
||||
t`These arguments are used with the specified module. You can find information about ${module_nameField.value} by clicking `
|
||||
)}
|
||||
<a
|
||||
href={`https://docs.ansible.com/ansible/latest/modules/${module_nameField.value}_module.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
{i18n._(t`here.`)}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
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(parseInt(value, 10));
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormField
|
||||
id="limit"
|
||||
name="limit"
|
||||
type="text"
|
||||
label={i18n._(t`Limit`)}
|
||||
tooltip={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<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 ansible configuration file. You can find more information`
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://docs.ansible.com/ansible/latest/installation_guide/intro_configuration.html#the-ansible-configuration-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{i18n._(t`here.`)}
|
||||
</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="diff_mode"
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={diff_modeField.value}
|
||||
onChange={() => {
|
||||
diff_modeHelpers.setValue(!diff_modeField.value);
|
||||
}}
|
||||
aria-label={i18n._(t`toggle changes`)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup name="become_enabled" fieldId="become_enabled">
|
||||
<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 ${brandName}
|
||||
and request a configuration update using this job
|
||||
template`)}
|
||||
|
||||
<code>--become </code>
|
||||
{i18n._(t`option to the`)}
|
||||
<code>ansible </code>
|
||||
{i18n._(t`command`)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
id="become_enabled"
|
||||
isChecked={become_enabledField.value}
|
||||
onChange={checked => {
|
||||
become_enabledHelpers.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>
|
||||
);
|
||||
}
|
||||
|
||||
CredentialStep.propTypes = {
|
||||
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialStep);
|
||||
@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Formik } from 'formik';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import DetailsStep from './AdHocDetailsStep';
|
||||
|
||||
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 initialValues = {
|
||||
limit: ['Inventory 1', 'inventory 2'],
|
||||
credential: [],
|
||||
module_args: '',
|
||||
arguments: '',
|
||||
verbosity: '',
|
||||
forks: 0,
|
||||
changes: false,
|
||||
escalation: false,
|
||||
extra_vars: '---',
|
||||
};
|
||||
|
||||
describe('<AdHocDetailsStep />', () => {
|
||||
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}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show all the fields', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik initialValues={initialValues}>
|
||||
<DetailsStep
|
||||
verbosityOptions={verbosityOptions}
|
||||
moduleOptions={moduleOptions}
|
||||
onLimitChange={onLimitChange}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('FormGroup[label="Module"]').length).toBe(1);
|
||||
expect(wrapper.find('FormField[label="Arguments"]').length).toBe(1);
|
||||
expect(wrapper.find('FormGroup[label="Verbosity"]').length).toBe(1);
|
||||
expect(wrapper.find('FormField[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="become_enabled"]').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}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
|
||||
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('input#limit').simulate('change', {
|
||||
target: {
|
||||
value: 'Inventory 1, inventory 2, new inventory',
|
||||
name: 'limit',
|
||||
},
|
||||
});
|
||||
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_name"]').prop('value')
|
||||
).toBe('command');
|
||||
expect(wrapper.find('input#module_args').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[name="limit"]').prop('value')).toBe(
|
||||
'Inventory 1, inventory 2, new inventory'
|
||||
);
|
||||
expect(wrapper.find('Switch').prop('isChecked')).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Checkbox[aria-label="Enable privilege escalation"]')
|
||||
.prop('isChecked')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
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';
|
||||
@ -83,7 +83,7 @@ describe('VariablesField', () => {
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('Tooltip').length).toBe(1);
|
||||
expect(wrapper.find('Popover').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should submit value through Formik', async () => {
|
||||
|
||||
@ -61,6 +61,6 @@ describe('FieldWithPrompt', () => {
|
||||
</Formik>
|
||||
);
|
||||
expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(1);
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||
expect(wrapper.find('Popover')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { node } from 'prop-types';
|
||||
import { Tooltip } from '@patternfly/react-core';
|
||||
import { Popover } from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@ -9,18 +9,20 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
`;
|
||||
|
||||
function FieldTooltip({ content, ...rest }) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
position="right"
|
||||
content={content}
|
||||
trigger="click mouseenter focus"
|
||||
<Popover
|
||||
bodyContent={content}
|
||||
isVisible={showTooltip}
|
||||
hideOnOutsideClick
|
||||
shouldClose={() => setShowTooltip(false)}
|
||||
{...rest}
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
<QuestionCircleIcon onClick={() => setShowTooltip(!showTooltip)} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
FieldTooltip.propTypes = {
|
||||
|
||||
@ -2,7 +2,13 @@ import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
DropdownItem,
|
||||
ToolbarGroup,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
@ -13,8 +19,11 @@ import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
|
||||
import InventoryGroupItem from './InventoryGroupItem';
|
||||
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
||||
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
|
||||
const QS_CONFIG = getQSConfig('group', {
|
||||
page: 1,
|
||||
@ -139,9 +148,38 @@ function InventoryGroupsList({ i18n }) {
|
||||
setSelected([]);
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const kebabedAdditionalControls = () => {
|
||||
return (
|
||||
<>
|
||||
<AdHocCommandsButton
|
||||
adHocItems={selected}
|
||||
apiModule={InventoriesAPI}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
>
|
||||
{({ openAdHocCommands }) => (
|
||||
<DropdownItem
|
||||
key="run command"
|
||||
onClick={openAdHocCommands}
|
||||
isDisabled={groupCount === 0}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
key="delete"
|
||||
onClick={toggleModal}
|
||||
isDisabled={selected.length === 0 || selected.some(cannotDelete)}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -211,20 +249,66 @@ function InventoryGroupsList({ i18n }) {
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<Tooltip content={renderTooltip()} position="top" key="delete">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={
|
||||
selected.length === 0 || selected.some(cannotDelete)
|
||||
}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
<Kebabified>
|
||||
{({ isKebabified }) => (
|
||||
<>
|
||||
{isKebabified ? (
|
||||
kebabedAdditionalControls()
|
||||
) : (
|
||||
<ToolbarGroup>
|
||||
<ToolbarItem>
|
||||
<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
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
apiModule={InventoriesAPI}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
>
|
||||
{({ openAdHocCommands }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={openAdHocCommands}
|
||||
isDisabled={groupCount === 0}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Tooltip
|
||||
content={renderTooltip()}
|
||||
position="top"
|
||||
key="delete"
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={
|
||||
selected.length === 0 ||
|
||||
selected.some(cannotDelete)
|
||||
}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Kebabified>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@ -241,6 +325,7 @@ function InventoryGroupsList({ i18n }) {
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
aria-label={i18n._(t`deletion error`)}
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
>
|
||||
|
||||
@ -88,6 +88,10 @@ describe('<InventoryGroupsList />', () => {
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('InventoryGroupsList').length).toBe(1);
|
||||
@ -143,15 +147,17 @@ describe('<InventoryGroupsList />', () => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
describe('<InventoryGroupsList/> error handling', () => {
|
||||
let wrapper;
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readGroupsOptions.mockImplementation(() =>
|
||||
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show content error if groups are not successfully fetched from api', async () => {
|
||||
@ -159,26 +165,27 @@ describe('<InventoryGroupsList />', () => {
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'InventoryGroupsDeleteModal',
|
||||
el => el.props().isModalOpen === true
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('ModalBoxFooter Button[aria-label="Delete"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show error modal when group is not successfully deleted from api', async () => {
|
||||
InventoriesAPI.readGroups.mockResolvedValue({
|
||||
data: {
|
||||
count: mockGroups.length,
|
||||
results: mockGroups,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readGroupsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
GroupsAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
@ -190,6 +197,25 @@ describe('<InventoryGroupsList />', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/inventories/inventory/:id/groups">
|
||||
<InventoryGroupsList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
});
|
||||
@ -213,11 +239,14 @@ describe('<InventoryGroupsList />', () => {
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'AlertModal[title="Error!"] Modal',
|
||||
'AlertModal[aria-label="deletion error"] Modal',
|
||||
el => el.props().isOpen === true && el.props().title === 'Error!'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
|
||||
wrapper
|
||||
.find('AlertModal[aria-label="deletion error"]')
|
||||
.invoke('onClose')();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField, useFormikContext } from 'formik';
|
||||
import { Switch, Text } from '@patternfly/react-core';
|
||||
import {
|
||||
@ -69,32 +69,30 @@ function CustomMessagesSubForm({ defaultMessages, type, i18n }) {
|
||||
css="margin-bottom: var(--pf-c-content--MarginBottom)"
|
||||
>
|
||||
<small>
|
||||
<Trans>
|
||||
Use custom messages to change the content of notifications sent
|
||||
when a job starts, succeeds, or fails. Use curly braces to
|
||||
access information about the job:{' '}
|
||||
<code>
|
||||
{'{{'} job_friendly_name {'}}'}
|
||||
</code>
|
||||
,{' '}
|
||||
<code>
|
||||
{'{{'} url {'}}'}
|
||||
</code>
|
||||
, or attributes of the job such as{' '}
|
||||
<code>
|
||||
{'{{'} job.status {'}}'}
|
||||
</code>
|
||||
. You may apply a number of possible variables in the message.
|
||||
Refer to the{' '}
|
||||
<a
|
||||
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/notifications.html#create-custom-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Ansible Tower documentation
|
||||
</a>{' '}
|
||||
for more details.
|
||||
</Trans>
|
||||
Use custom messages to change the content of notifications sent
|
||||
when a job starts, succeeds, or fails. Use curly braces to access
|
||||
information about the job:{' '}
|
||||
<code>
|
||||
{'{{'} job_friendly_name {'}}'}
|
||||
</code>
|
||||
,{' '}
|
||||
<code>
|
||||
{'{{'} url {'}}'}
|
||||
</code>
|
||||
, or attributes of the job such as{' '}
|
||||
<code>
|
||||
{'{{'} job.status {'}}'}
|
||||
</code>
|
||||
. You may apply a number of possible variables in the message.
|
||||
Refer to the{' '}
|
||||
<a
|
||||
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/notifications.html#create-custom-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Ansible Tower documentation
|
||||
</a>{' '}
|
||||
for more details.
|
||||
</small>
|
||||
</Text>
|
||||
<FormFullWidthLayout>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user