mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Adds Proptypes and updates tooltips to make them more translatable
This commit is contained in:
parent
e6ae171f4b
commit
94469cc8c0
@ -2,23 +2,30 @@ 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 AdHocCommandsForm from './AdHocCommandsWizard';
|
||||
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, verbosityOptions, credentialTypeId },
|
||||
result: { moduleOptions, credentialTypeId },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [choices, credId] = await Promise.all([
|
||||
@ -38,13 +45,8 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
(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]),
|
||||
@ -65,6 +67,7 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
const { data } = await apiModule.launchAdHocCommands(itemId, values);
|
||||
history.push(`/jobs/${data.module_name}/${data.id}/output`);
|
||||
},
|
||||
|
||||
[apiModule, itemId, history]
|
||||
)
|
||||
);
|
||||
@ -73,16 +76,11 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
launchError || fetchError
|
||||
);
|
||||
|
||||
const handleSubmit = async (values, limitTypedValue) => {
|
||||
const { credential, limit, ...remainingValues } = values;
|
||||
const handleSubmit = async values => {
|
||||
const { credential, ...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,
|
||||
};
|
||||
@ -105,7 +103,14 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
setIsWizardOpen(false);
|
||||
}}
|
||||
>
|
||||
<ContentError error={error} />
|
||||
{launchError ? (
|
||||
<>
|
||||
{i18n._(t`Failed to launch job.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</>
|
||||
) : (
|
||||
<ContentError error={error} />
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
@ -116,30 +121,24 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
})}
|
||||
|
||||
{isWizardOpen && (
|
||||
<AdHocCommandsForm
|
||||
<AdHocCommandsWizard
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
AdHocCommands.propTypes = {
|
||||
children: PropTypes.func.isRequired,
|
||||
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(AdHocCommands);
|
||||
|
||||
@ -20,7 +20,7 @@ const credentials = [
|
||||
];
|
||||
const adHocItems = [
|
||||
{
|
||||
name: ' Inventory 1 Org 0',
|
||||
name: 'Inventory 1 Org 0',
|
||||
},
|
||||
{ name: 'Inventory 2 Org 0' },
|
||||
];
|
||||
@ -43,6 +43,7 @@ describe('<AdHocCOmmands />', () => {
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
@ -57,6 +58,7 @@ describe('<AdHocCOmmands />', () => {
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
@ -91,12 +93,13 @@ describe('<AdHocCOmmands />', () => {
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
wrapper.find('button').prop('onClick')();
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
|
||||
wrapper.update();
|
||||
|
||||
@ -128,29 +131,32 @@ describe('<AdHocCOmmands />', () => {
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
wrapper.find('button').prop('onClick')();
|
||||
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"]')
|
||||
.find('WizardNavItem[content="Machine credential"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
@ -194,7 +200,7 @@ describe('<AdHocCOmmands />', () => {
|
||||
escalation: false,
|
||||
extra_vars: '---',
|
||||
forks: 0,
|
||||
limit: 'I',
|
||||
limit: 'Inventory 1 Org 0, Inventory 2 Org 0',
|
||||
module_args: 'command',
|
||||
verbosity: 1,
|
||||
});
|
||||
@ -203,6 +209,7 @@ describe('<AdHocCOmmands />', () => {
|
||||
|
||||
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should throw error on submission properly', async () => {
|
||||
InventoriesAPI.launchAdHocCommands.mockRejectedValue(
|
||||
new Error({
|
||||
@ -245,24 +252,25 @@ describe('<AdHocCOmmands />', () => {
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId="a"
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
wrapper.find('button').prop('onClick')();
|
||||
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"]')
|
||||
.find('WizardNavItem[content="Machine credential"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
@ -327,12 +335,13 @@ describe('<AdHocCOmmands />', () => {
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
wrapper.find('button').prop('onClick')();
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
@ -2,10 +2,11 @@ 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 DetailsStep from './DetailsStep';
|
||||
import AdHocDetailsStep from './AdHocDetailsStep';
|
||||
|
||||
function AdHocCommandsWizard({
|
||||
onLaunch,
|
||||
@ -16,31 +17,43 @@ function AdHocCommandsWizard({
|
||||
credentialTypeId,
|
||||
}) {
|
||||
const [currentStepId, setCurrentStepId] = useState(1);
|
||||
const [limitTypedValue, setLimitTypedValue] = useState('');
|
||||
const [enableLaunch, setEnableLaunch] = useState(false);
|
||||
|
||||
const { values } = useFormikContext();
|
||||
|
||||
const enabledNextOnDetailsStep = () => {
|
||||
if (!values.module_args) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (values.module_args === 'shell' || values.module_args === 'command') {
|
||||
if (values.arguments) {
|
||||
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: (
|
||||
<DetailsStep
|
||||
<AdHocDetailsStep
|
||||
moduleOptions={moduleOptions}
|
||||
verbosityOptions={verbosityOptions}
|
||||
onLimitChange={value => setLimitTypedValue(value)}
|
||||
limitValue={limitTypedValue}
|
||||
/>
|
||||
),
|
||||
enableNext: values.module_args && values.arguments && values.verbosity,
|
||||
enableNext: enabledNextOnDetailsStep(),
|
||||
nextButtonText: i18n._(t`Next`),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 2,
|
||||
name: i18n._(t`Machine Credential`),
|
||||
name: i18n._(t`Machine credential`),
|
||||
component: (
|
||||
<AdHocCredentialStep
|
||||
credentialTypeId={credentialTypeId}
|
||||
@ -55,19 +68,17 @@ function AdHocCommandsWizard({
|
||||
|
||||
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}
|
||||
onSave={() => {
|
||||
onLaunch(values);
|
||||
}}
|
||||
steps={steps}
|
||||
title={i18n._(t`Ad Hoc Commands`)}
|
||||
title={i18n._(t`Run command`)}
|
||||
nextButtonText={currentStep.nextButtonText || undefined}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
@ -76,14 +87,14 @@ function AdHocCommandsWizard({
|
||||
}
|
||||
|
||||
const FormikApp = withFormik({
|
||||
mapPropsToValues({ adHocItems }) {
|
||||
const adHocItemStrings = adHocItems.map(item => item.name);
|
||||
mapPropsToValues({ adHocItems, verbosityOptions }) {
|
||||
const adHocItemStrings = adHocItems.map(item => item.name).join(', ');
|
||||
return {
|
||||
limit: adHocItemStrings || [],
|
||||
credential: [],
|
||||
module_args: '',
|
||||
arguments: '',
|
||||
verbosity: '',
|
||||
verbosity: verbosityOptions[0].value,
|
||||
forks: 0,
|
||||
changes: false,
|
||||
escalation: false,
|
||||
@ -92,4 +103,11 @@ const FormikApp = withFormik({
|
||||
},
|
||||
})(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);
|
||||
|
||||
@ -10,7 +10,13 @@ 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' },
|
||||
@ -26,7 +32,7 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
adHocItems={adHocItems}
|
||||
onLaunch={onLaunch}
|
||||
moduleOptions={[]}
|
||||
verbosityOptions={[]}
|
||||
verbosityOptions={verbosityOptions}
|
||||
onCloseWizard={() => {}}
|
||||
credentialTypeId={1}
|
||||
/>
|
||||
@ -39,14 +45,11 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@ -55,12 +58,12 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[content="Machine Credential"]')
|
||||
.find('WizardNavItem[content="Machine credential"]')
|
||||
.prop('isDisabled')
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WizardNavItem[content="Machine Credential"]')
|
||||
.find('WizardNavItem[content="Machine credential"]')
|
||||
.prop('isCurrent')
|
||||
).toBe(false);
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
|
||||
@ -69,7 +72,7 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
test('next button should become active, and should navigate to the next step', async () => {
|
||||
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
@ -83,22 +86,25 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')();
|
||||
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' },
|
||||
{ id: 2, name: 'Cred2' },
|
||||
{ id: 1, name: 'Cred 1', url: '' },
|
||||
{ id: 2, name: 'Cred2', url: '' },
|
||||
],
|
||||
count: 2,
|
||||
},
|
||||
});
|
||||
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
@ -112,7 +118,9 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
wrapper.update();
|
||||
await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
|
||||
@ -133,7 +141,11 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')();
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
expect(onLaunch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -152,7 +164,7 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
);
|
||||
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
@ -166,11 +178,12 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')();
|
||||
|
||||
await act(async () =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,9 +2,11 @@ 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';
|
||||
@ -68,6 +70,13 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
|
||||
!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 || []}
|
||||
@ -111,4 +120,8 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
|
||||
);
|
||||
}
|
||||
|
||||
AdHocCredentialStep.propTypes = {
|
||||
credentialTypeId: PropTypes.number.isRequired,
|
||||
onEnableLaunch: PropTypes.func.isRequired,
|
||||
};
|
||||
export default withI18n()(AdHocCredentialStep);
|
||||
|
||||
@ -2,20 +2,12 @@
|
||||
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,
|
||||
InputGroup,
|
||||
TextInput,
|
||||
Label,
|
||||
InputGroupText,
|
||||
Switch,
|
||||
Checkbox,
|
||||
} from '@patternfly/react-core';
|
||||
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';
|
||||
@ -30,18 +22,16 @@ const TooltipWrapper = styled.div`
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
function CredentialStep({
|
||||
i18n,
|
||||
verbosityOptions,
|
||||
moduleOptions,
|
||||
onLimitChange,
|
||||
limitValue,
|
||||
}) {
|
||||
// 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 [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');
|
||||
@ -65,7 +55,7 @@ function CredentialStep({
|
||||
labelIcon={
|
||||
<FieldTooltip
|
||||
content={i18n._(
|
||||
t`These are the modules that AWX supports running commands against.`
|
||||
t`These are the modules that ${brandName} supports running commands against.`
|
||||
)}
|
||||
/>
|
||||
}
|
||||
@ -85,7 +75,9 @@ function CredentialStep({
|
||||
name="arguments"
|
||||
type="text"
|
||||
label={i18n._(t`Arguments`)}
|
||||
isRequired
|
||||
isRequired={
|
||||
moduleField.value === 'command' || moduleField.value === 'shell'
|
||||
}
|
||||
tooltip={i18n._(
|
||||
t`These arguments are used with the specified module.`
|
||||
)}
|
||||
@ -118,54 +110,26 @@ function CredentialStep({
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
<FormField
|
||||
id="limit"
|
||||
name="limit"
|
||||
type="text"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
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>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
@ -175,14 +139,14 @@ function CredentialStep({
|
||||
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 `
|
||||
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`ansible configuration file.`)}
|
||||
{i18n._(t`here.`)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@ -225,13 +189,13 @@ function CredentialStep({
|
||||
content={
|
||||
<p>
|
||||
{i18n._(t`Enables creation of a provisioning
|
||||
callback URL. Using the URL a host can contact BRAND_NAME
|
||||
callback URL. Using the URL a host can contact ${brandName}
|
||||
and request a configuration update using this job
|
||||
template`)}
|
||||
|
||||
<code>--{i18n._(t`become`)} </code>
|
||||
<code>--become </code>
|
||||
{i18n._(t`option to the`)}
|
||||
<code>{i18n._(t`ansible`)} </code>
|
||||
<code>ansible </code>
|
||||
{i18n._(t`command`)}
|
||||
</p>
|
||||
}
|
||||
@ -297,4 +261,9 @@ function CredentialStep({
|
||||
);
|
||||
}
|
||||
|
||||
CredentialStep.propTypes = {
|
||||
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialStep);
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Formik } from 'formik';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import DetailsStep from './DetailsStep';
|
||||
import DetailsStep from './AdHocDetailsStep';
|
||||
|
||||
jest.mock('../../api/models/Credentials');
|
||||
|
||||
@ -17,7 +17,6 @@ const moduleOptions = [
|
||||
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
|
||||
];
|
||||
const onLimitChange = jest.fn();
|
||||
const limitValue = '';
|
||||
const initialValues = {
|
||||
limit: ['Inventory 1', 'inventory 2'],
|
||||
credential: [],
|
||||
@ -46,7 +45,6 @@ describe('<DetailsStep />', () => {
|
||||
verbosityOptions={verbosityOptions}
|
||||
moduleOptions={moduleOptions}
|
||||
onLimitChange={onLimitChange}
|
||||
limitValue={limitValue}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@ -61,7 +59,6 @@ describe('<DetailsStep />', () => {
|
||||
verbosityOptions={verbosityOptions}
|
||||
moduleOptions={moduleOptions}
|
||||
onLimitChange={onLimitChange}
|
||||
limitValue={limitValue}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@ -69,7 +66,7 @@ describe('<DetailsStep />', () => {
|
||||
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[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(
|
||||
@ -86,7 +83,6 @@ describe('<DetailsStep />', () => {
|
||||
verbosityOptions={verbosityOptions}
|
||||
moduleOptions={moduleOptions}
|
||||
onLimitChange={onLimitChange}
|
||||
limitValue={limitValue}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@ -100,6 +96,12 @@ describe('<DetailsStep />', () => {
|
||||
wrapper.find('input#arguments').simulate('change', {
|
||||
target: { value: 'foo', name: 'arguments' },
|
||||
});
|
||||
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', {
|
||||
@ -121,7 +123,9 @@ describe('<DetailsStep />', () => {
|
||||
1
|
||||
);
|
||||
expect(wrapper.find('TextInputBase[name="forks"]').prop('value')).toBe(10);
|
||||
expect(wrapper.find('TextInputBase[label="Limit"]').prop('value')).toBe('');
|
||||
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
|
||||
@ -129,22 +133,4 @@ describe('<DetailsStep />', () => {
|
||||
.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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
@ -17,6 +23,7 @@ import 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,
|
||||
@ -141,9 +148,37 @@ 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="add"
|
||||
onClick={openAdHocCommands}
|
||||
isDisabled={groupCount === 0}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={selected.length === 0 || selected.some(cannotDelete)}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -213,48 +248,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>,
|
||||
[
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
</Tooltip>,
|
||||
],
|
||||
</>
|
||||
)}
|
||||
</Kebabified>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@ -271,6 +324,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')();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user