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:
softwarefactory-project-zuul[bot] 2020-09-09 20:21:41 +00:00 committed by GitHub
commit dff7667532
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1594 additions and 74 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,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);

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

@ -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 = {

View File

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

View File

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

View File

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