Adds Ad Hoc Commands Wizard

This commit is contained in:
Alex Corey 2020-08-13 15:38:18 -04:00
parent caa7b43fe0
commit e6ae171f4b
11 changed files with 1412 additions and 0 deletions

View File

@ -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;

View 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);

View 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);
});
});

View File

@ -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);

View File

@ -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);
});
});

View 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);

View File

@ -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);
});
});

View 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 Ansibles --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`)}
&nbsp;
<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`)}
&nbsp;
<code>--{i18n._(t`become`)} &nbsp;</code>
{i18n._(t`option to the`)} &nbsp;
<code>{i18n._(t`ansible`)} &nbsp;</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);

View 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'
);
});
});

View File

@ -0,0 +1 @@
export { default } from './AdHocCommands';

View File

@ -13,8 +13,10 @@ 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';
const QS_CONFIG = getQSConfig('group', {
page: 1,
@ -225,6 +227,34 @@ function InventoryGroupsList({ i18n }) {
</Button>
</div>
</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>,
],
]}
/>
)}