Adds Proptypes and updates tooltips to make them more translatable

This commit is contained in:
Alex Corey
2020-08-13 17:35:00 -04:00
parent e6ae171f4b
commit 94469cc8c0
9 changed files with 325 additions and 235 deletions

View File

@@ -2,23 +2,30 @@ import React, { useState, Fragment, useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { CredentialTypesAPI } from '../../api'; import { CredentialTypesAPI } from '../../api';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import AdHocCommandsForm from './AdHocCommandsWizard'; import AdHocCommandsWizard from './AdHocCommandsWizard';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError'; import ContentError from '../ContentError';
function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const history = useHistory(); 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 { const {
error: fetchError, error: fetchError,
request: fetchModuleOptions, request: fetchModuleOptions,
result: { moduleOptions, verbosityOptions, credentialTypeId }, result: { moduleOptions, credentialTypeId },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const [choices, credId] = await Promise.all([ const [choices, credId] = await Promise.all([
@@ -38,13 +45,8 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
(choice, index) => itemObject(choice[0], index) (choice, index) => itemObject(choice[0], index)
); );
const verbosityItems = choices.data.actions.GET.verbosity.choices.map(
(choice, index) => itemObject(choice[0], index)
);
return { return {
moduleOptions: [itemObject('', -1), ...options], moduleOptions: [itemObject('', -1), ...options],
verbosityOptions: [itemObject('', -1), ...verbosityItems],
credentialTypeId: credId.data.results[0].id, credentialTypeId: credId.data.results[0].id,
}; };
}, [itemId, apiModule]), }, [itemId, apiModule]),
@@ -65,6 +67,7 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
const { data } = await apiModule.launchAdHocCommands(itemId, values); const { data } = await apiModule.launchAdHocCommands(itemId, values);
history.push(`/jobs/${data.module_name}/${data.id}/output`); history.push(`/jobs/${data.module_name}/${data.id}/output`);
}, },
[apiModule, itemId, history] [apiModule, itemId, history]
) )
); );
@@ -73,16 +76,11 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
launchError || fetchError launchError || fetchError
); );
const handleSubmit = async (values, limitTypedValue) => { const handleSubmit = async values => {
const { credential, limit, ...remainingValues } = values; const { credential, ...remainingValues } = values;
const newCredential = credential[0].id; const newCredential = credential[0].id;
if (limitTypedValue) {
values.limit = limit.concat(limitTypedValue);
}
const stringifyLimit = values.limit.join(', ').trim();
const manipulatedValues = { const manipulatedValues = {
limit: stringifyLimit[0],
credential: newCredential, credential: newCredential,
...remainingValues, ...remainingValues,
}; };
@@ -105,7 +103,14 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
setIsWizardOpen(false); setIsWizardOpen(false);
}} }}
> >
<ContentError error={error} /> {launchError ? (
<>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={error} />
</>
) : (
<ContentError error={error} />
)}
</AlertModal> </AlertModal>
); );
} }
@@ -116,30 +121,24 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
})} })}
{isWizardOpen && ( {isWizardOpen && (
<AdHocCommandsForm <AdHocCommandsWizard
adHocItems={adHocItems} adHocItems={adHocItems}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions} verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId} credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)} onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit} onLaunch={handleSubmit}
error={error}
onDismissError={() => dismissError()} 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> </Fragment>
); );
} }
AdHocCommands.propTypes = {
children: PropTypes.func.isRequired,
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired,
};
export default withI18n()(AdHocCommands); export default withI18n()(AdHocCommands);

View File

@@ -20,7 +20,7 @@ const credentials = [
]; ];
const adHocItems = [ const adHocItems = [
{ {
name: ' Inventory 1 Org 0', name: 'Inventory 1 Org 0',
}, },
{ name: 'Inventory 2 Org 0' }, { name: 'Inventory 2 Org 0' },
]; ];
@@ -43,6 +43,7 @@ describe('<AdHocCOmmands />', () => {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId={1} itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
@@ -57,6 +58,7 @@ describe('<AdHocCOmmands />', () => {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId={1} itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
@@ -91,12 +93,13 @@ describe('<AdHocCOmmands />', () => {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId={1} itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
); );
}); });
wrapper.find('button').prop('onClick')(); await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
@@ -128,29 +131,32 @@ describe('<AdHocCOmmands />', () => {
count: 5, count: 5,
}, },
}); });
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId={1} itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
); );
}); });
wrapper.find('button').prop('onClick')(); await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect( expect(
wrapper wrapper
.find('WizardNavItem[content="Machine Credential"]') .find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled') .prop('isDisabled')
).toBe(true); ).toBe(true);
act(() => { await act(async () => {
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
{}, {},
'command' 'command'
@@ -194,7 +200,7 @@ describe('<AdHocCOmmands />', () => {
escalation: false, escalation: false,
extra_vars: '---', extra_vars: '---',
forks: 0, forks: 0,
limit: 'I', limit: 'Inventory 1 Org 0, Inventory 2 Org 0',
module_args: 'command', module_args: 'command',
verbosity: 1, verbosity: 1,
}); });
@@ -203,6 +209,7 @@ describe('<AdHocCOmmands />', () => {
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0); expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
}); });
test('should throw error on submission properly', async () => { test('should throw error on submission properly', async () => {
InventoriesAPI.launchAdHocCommands.mockRejectedValue( InventoriesAPI.launchAdHocCommands.mockRejectedValue(
new Error({ new Error({
@@ -245,24 +252,25 @@ describe('<AdHocCOmmands />', () => {
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId="a" itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
); );
}); });
wrapper.find('button').prop('onClick')(); await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
expect( expect(
wrapper wrapper
.find('WizardNavItem[content="Machine Credential"]') .find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled') .prop('isDisabled')
).toBe(true); ).toBe(true);
act(() => { await act(async () => {
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
{}, {},
'command' 'command'
@@ -327,12 +335,13 @@ describe('<AdHocCOmmands />', () => {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
adHocItems={adHocItems} adHocItems={adHocItems}
itemId={1} itemId={1}
credentialTypeId={1}
> >
{children} {children}
</AdHocCommands> </AdHocCommands>
); );
}); });
wrapper.find('button').prop('onClick')(); await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1); expect(wrapper.find('ErrorDetail').length).toBe(1);
}); });

View File

@@ -2,10 +2,11 @@ import React, { useState } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withFormik, useFormikContext } from 'formik'; import { withFormik, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import Wizard from '../Wizard'; import Wizard from '../Wizard';
import AdHocCredentialStep from './AdHocCredentialStep'; import AdHocCredentialStep from './AdHocCredentialStep';
import DetailsStep from './DetailsStep'; import AdHocDetailsStep from './AdHocDetailsStep';
function AdHocCommandsWizard({ function AdHocCommandsWizard({
onLaunch, onLaunch,
@@ -16,31 +17,43 @@ function AdHocCommandsWizard({
credentialTypeId, credentialTypeId,
}) { }) {
const [currentStepId, setCurrentStepId] = useState(1); const [currentStepId, setCurrentStepId] = useState(1);
const [limitTypedValue, setLimitTypedValue] = useState('');
const [enableLaunch, setEnableLaunch] = useState(false); const [enableLaunch, setEnableLaunch] = useState(false);
const { values } = useFormikContext(); 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 = [ const steps = [
{ {
id: 1, id: 1,
key: 1, key: 1,
name: i18n._(t`Details`), name: i18n._(t`Details`),
component: ( component: (
<DetailsStep <AdHocDetailsStep
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions} verbosityOptions={verbosityOptions}
onLimitChange={value => setLimitTypedValue(value)}
limitValue={limitTypedValue}
/> />
), ),
enableNext: values.module_args && values.arguments && values.verbosity, enableNext: enabledNextOnDetailsStep(),
nextButtonText: i18n._(t`Next`), nextButtonText: i18n._(t`Next`),
}, },
{ {
id: 2, id: 2,
key: 2, key: 2,
name: i18n._(t`Machine Credential`), name: i18n._(t`Machine credential`),
component: ( component: (
<AdHocCredentialStep <AdHocCredentialStep
credentialTypeId={credentialTypeId} credentialTypeId={credentialTypeId}
@@ -55,19 +68,17 @@ function AdHocCommandsWizard({
const currentStep = steps.find(step => step.id === currentStepId); const currentStep = steps.find(step => step.id === currentStepId);
const submit = () => {
onLaunch(values, limitTypedValue);
};
return ( return (
<Wizard <Wizard
style={{ overflow: 'scroll' }} style={{ overflow: 'scroll' }}
isOpen isOpen
onNext={step => setCurrentStepId(step.id)} onNext={step => setCurrentStepId(step.id)}
onClose={() => onCloseWizard()} onClose={() => onCloseWizard()}
onSave={submit} onSave={() => {
onLaunch(values);
}}
steps={steps} steps={steps}
title={i18n._(t`Ad Hoc Commands`)} title={i18n._(t`Run command`)}
nextButtonText={currentStep.nextButtonText || undefined} nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={i18n._(t`Back`)} backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)} cancelButtonText={i18n._(t`Cancel`)}
@@ -76,14 +87,14 @@ function AdHocCommandsWizard({
} }
const FormikApp = withFormik({ const FormikApp = withFormik({
mapPropsToValues({ adHocItems }) { mapPropsToValues({ adHocItems, verbosityOptions }) {
const adHocItemStrings = adHocItems.map(item => item.name); const adHocItemStrings = adHocItems.map(item => item.name).join(', ');
return { return {
limit: adHocItemStrings || [], limit: adHocItemStrings || [],
credential: [], credential: [],
module_args: '', module_args: '',
arguments: '', arguments: '',
verbosity: '', verbosity: verbosityOptions[0].value,
forks: 0, forks: 0,
changes: false, changes: false,
escalation: false, escalation: false,
@@ -92,4 +103,11 @@ const FormikApp = withFormik({
}, },
})(AdHocCommandsWizard); })(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); export default withI18n()(FormikApp);

View File

@@ -10,7 +10,13 @@ import AdHocCommandsWizard from './AdHocCommandsWizard';
jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/CredentialTypes');
jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Inventories');
jest.mock('../../api/models/Credentials'); 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 = [ const adHocItems = [
{ name: 'Inventory 1' }, { name: 'Inventory 1' },
{ name: 'Inventory 2' }, { name: 'Inventory 2' },
@@ -26,7 +32,7 @@ describe('<AdHocCommandsWizard/>', () => {
adHocItems={adHocItems} adHocItems={adHocItems}
onLaunch={onLaunch} onLaunch={onLaunch}
moduleOptions={[]} moduleOptions={[]}
verbosityOptions={[]} verbosityOptions={verbosityOptions}
onCloseWizard={() => {}} onCloseWizard={() => {}}
credentialTypeId={1} credentialTypeId={1}
/> />
@@ -39,14 +45,11 @@ describe('<AdHocCommandsWizard/>', () => {
}); });
test('should mount properly', async () => { test('should mount properly', async () => {
// wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1); expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
}); });
test('next and nav item should be disabled', async () => { test('next and nav item should be disabled', async () => {
// wrapper.update();
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
expect( expect(
wrapper.find('WizardNavItem[content="Details"]').prop('isCurrent') wrapper.find('WizardNavItem[content="Details"]').prop('isCurrent')
).toBe(true); ).toBe(true);
@@ -55,12 +58,12 @@ describe('<AdHocCommandsWizard/>', () => {
).toBe(false); ).toBe(false);
expect( expect(
wrapper wrapper
.find('WizardNavItem[content="Machine Credential"]') .find('WizardNavItem[content="Machine credential"]')
.prop('isDisabled') .prop('isDisabled')
).toBe(true); ).toBe(true);
expect( expect(
wrapper wrapper
.find('WizardNavItem[content="Machine Credential"]') .find('WizardNavItem[content="Machine credential"]')
.prop('isCurrent') .prop('isCurrent')
).toBe(false); ).toBe(false);
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); 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 () => { test('next button should become active, and should navigate to the next step', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
act(() => { await act(async () => {
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
{}, {},
'command' 'command'
@@ -83,22 +86,25 @@ describe('<AdHocCommandsWizard/>', () => {
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false false
); );
wrapper.find('Button[type="submit"]').prop('onClick')(); await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
}); });
test('launch button should become active', async () => { test('launch button should become active', async () => {
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {
results: [ results: [
{ id: 1, name: 'Cred 1' }, { id: 1, name: 'Cred 1', url: '' },
{ id: 2, name: 'Cred2' }, { id: 2, name: 'Cred2', url: '' },
], ],
count: 2, count: 2,
}, },
}); });
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
act(() => { await act(async () => {
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
{}, {},
'command' 'command'
@@ -112,7 +118,9 @@ describe('<AdHocCommandsWizard/>', () => {
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false false
); );
wrapper.find('Button[type="submit"]').prop('onClick')(); await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
await waitForElement(wrapper, 'OptionsList', el => el.length > 0); await waitForElement(wrapper, 'OptionsList', el => el.length > 0);
@@ -133,7 +141,11 @@ describe('<AdHocCommandsWizard/>', () => {
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false false
); );
wrapper.find('Button[type="submit"]').prop('onClick')();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
expect(onLaunch).toHaveBeenCalled(); expect(onLaunch).toHaveBeenCalled();
}); });
@@ -152,7 +164,7 @@ describe('<AdHocCommandsWizard/>', () => {
); );
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0); await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
act(() => { await act(async () => {
wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')( wrapper.find('AnsibleSelect[name="module_args"]').prop('onChange')(
{}, {},
'command' 'command'
@@ -166,11 +178,12 @@ describe('<AdHocCommandsWizard/>', () => {
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(
false false
); );
wrapper.find('Button[type="submit"]').prop('onClick')();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update(); wrapper.update();
expect(wrapper.find('ContentLoading').length).toBe(1);
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1); expect(wrapper.find('ContentError').length).toBe(1);
}); });
}); });

View File

@@ -2,9 +2,11 @@ import React, { useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { useField } from 'formik'; import { useField } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup } from '@patternfly/react-core';
import { CredentialsAPI } from '../../api'; import { CredentialsAPI } from '../../api';
import { FieldTooltip } from '../FormField';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
@@ -68,6 +70,13 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error' !credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'
} }
helperTextInvalid={credentialMeta.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 <OptionsList
value={credentialField.value || []} 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); export default withI18n()(AdHocCredentialStep);

View File

@@ -2,20 +2,12 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { useField } from 'formik'; import { useField } from 'formik';
import { import { Form, FormGroup, Switch, Checkbox } from '@patternfly/react-core';
Form,
FormGroup,
InputGroup,
TextInput,
Label,
InputGroupText,
Switch,
Checkbox,
} from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { BrandName } from '../../variables';
import AnsibleSelect from '../AnsibleSelect'; import AnsibleSelect from '../AnsibleSelect';
import FormField, { FieldTooltip } from '../FormField'; import FormField, { FieldTooltip } from '../FormField';
import { VariablesField } from '../CodeMirrorInput'; import { VariablesField } from '../CodeMirrorInput';
@@ -30,18 +22,16 @@ const TooltipWrapper = styled.div`
text-align: left; text-align: left;
`; `;
function CredentialStep({ // Setting BrandName to a variable here is necessary to get the jest tests
i18n, // passing. Attempting to use BrandName in the template literal results
verbosityOptions, // in failing tests.
moduleOptions, const brandName = BrandName;
onLimitChange,
limitValue, function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
}) {
const [moduleField, moduleMeta, moduleHelpers] = useField({ const [moduleField, moduleMeta, moduleHelpers] = useField({
name: 'module_args', name: 'module_args',
validate: required(null, i18n), validate: required(null, i18n),
}); });
const [limitField, , limitHelpers] = useField('limit');
const [variablesField] = useField('extra_vars'); const [variablesField] = useField('extra_vars');
const [changesField, , changesHelpers] = useField('changes'); const [changesField, , changesHelpers] = useField('changes');
const [escalationField, , escalationHelpers] = useField('escalation'); const [escalationField, , escalationHelpers] = useField('escalation');
@@ -65,7 +55,7 @@ function CredentialStep({
labelIcon={ labelIcon={
<FieldTooltip <FieldTooltip
content={i18n._( 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" name="arguments"
type="text" type="text"
label={i18n._(t`Arguments`)} label={i18n._(t`Arguments`)}
isRequired isRequired={
moduleField.value === 'command' || moduleField.value === 'shell'
}
tooltip={i18n._( tooltip={i18n._(
t`These arguments are used with the specified module.` t`These arguments are used with the specified module.`
)} )}
@@ -118,54 +110,26 @@ function CredentialStep({
}} }}
/> />
</FormGroup> </FormGroup>
<FormField
<FormGroup id="limit"
name="limit"
type="text"
label={i18n._(t`Limit`)} label={i18n._(t`Limit`)}
labelIcon={ tooltip={
<FieldTooltip <span>
content={ {i18n._(
<span> 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`
{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"
<a target="_blank"
href="https://docs.ansible.com/ansible/latest/user_guide/intro_patterns.html" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" {i18n._(`here`)}
> </a>
{i18n._(`here`)} </span>
</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 <FormField
id="template-forks" id="template-forks"
name="forks" name="forks"
@@ -175,14 +139,14 @@ function CredentialStep({
tooltip={ tooltip={
<span> <span>
{i18n._( {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 <a
href="https://docs.ansible.com/ansible/latest/installation_guide/intro_configuration.html#the-ansible-configuration-file" href="https://docs.ansible.com/ansible/latest/installation_guide/intro_configuration.html#the-ansible-configuration-file"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{i18n._(t`ansible configuration file.`)} {i18n._(t`here.`)}
</a> </a>
</span> </span>
} }
@@ -225,13 +189,13 @@ function CredentialStep({
content={ content={
<p> <p>
{i18n._(t`Enables creation of a provisioning {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 and request a configuration update using this job
template`)} template`)}
&nbsp; &nbsp;
<code>--{i18n._(t`become`)} &nbsp;</code> <code>--become </code>
{i18n._(t`option to the`)} &nbsp; {i18n._(t`option to the`)} &nbsp;
<code>{i18n._(t`ansible`)} &nbsp;</code> <code>ansible </code>
{i18n._(t`command`)} {i18n._(t`command`)}
</p> </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); export default withI18n()(CredentialStep);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import DetailsStep from './DetailsStep'; import DetailsStep from './AdHocDetailsStep';
jest.mock('../../api/models/Credentials'); jest.mock('../../api/models/Credentials');
@@ -17,7 +17,6 @@ const moduleOptions = [
{ key: 1, value: 'shell', label: 'shell', isDisabled: false }, { key: 1, value: 'shell', label: 'shell', isDisabled: false },
]; ];
const onLimitChange = jest.fn(); const onLimitChange = jest.fn();
const limitValue = '';
const initialValues = { const initialValues = {
limit: ['Inventory 1', 'inventory 2'], limit: ['Inventory 1', 'inventory 2'],
credential: [], credential: [],
@@ -46,7 +45,6 @@ describe('<DetailsStep />', () => {
verbosityOptions={verbosityOptions} verbosityOptions={verbosityOptions}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
onLimitChange={onLimitChange} onLimitChange={onLimitChange}
limitValue={limitValue}
/> />
</Formik> </Formik>
); );
@@ -61,7 +59,6 @@ describe('<DetailsStep />', () => {
verbosityOptions={verbosityOptions} verbosityOptions={verbosityOptions}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
onLimitChange={onLimitChange} onLimitChange={onLimitChange}
limitValue={limitValue}
/> />
</Formik> </Formik>
); );
@@ -69,7 +66,7 @@ describe('<DetailsStep />', () => {
expect(wrapper.find('FormGroup[label="Module"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Module"]').length).toBe(1);
expect(wrapper.find('FormField[name="arguments"]').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="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('FormField[name="forks"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Show changes"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Show changes"]').length).toBe(1);
expect( expect(
@@ -86,7 +83,6 @@ describe('<DetailsStep />', () => {
verbosityOptions={verbosityOptions} verbosityOptions={verbosityOptions}
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
onLimitChange={onLimitChange} onLimitChange={onLimitChange}
limitValue={limitValue}
/> />
</Formik> </Formik>
); );
@@ -100,6 +96,12 @@ describe('<DetailsStep />', () => {
wrapper.find('input#arguments').simulate('change', { wrapper.find('input#arguments').simulate('change', {
target: { value: 'foo', name: 'arguments' }, 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('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1);
wrapper.find('TextInputBase[name="forks"]').simulate('change', { wrapper.find('TextInputBase[name="forks"]').simulate('change', {
@@ -121,7 +123,9 @@ describe('<DetailsStep />', () => {
1 1
); );
expect(wrapper.find('TextInputBase[name="forks"]').prop('value')).toBe(10); 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.find('Switch').prop('isChecked')).toBe(true);
expect( expect(
wrapper wrapper
@@ -129,22 +133,4 @@ describe('<DetailsStep />', () => {
.prop('isChecked') .prop('isChecked')
).toBe(true); ).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

@@ -2,7 +2,13 @@ import React, { useCallback, useState, useEffect } from 'react';
import { useParams, useLocation } from 'react-router-dom'; import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; 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 { getQSConfig, parseQueryString } from '../../../util/qs';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
@@ -17,6 +23,7 @@ import PaginatedDataList, {
import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands'; import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import { Kebabified } from '../../../contexts/Kebabified';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
page: 1, page: 1,
@@ -141,9 +148,37 @@ function InventoryGroupsList({ i18n }) {
setSelected([]); setSelected([]);
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); 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 ( return (
<> <>
@@ -213,48 +248,66 @@ function InventoryGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Tooltip content={renderTooltip()} position="top" key="delete"> <Kebabified>
<div> {({ isKebabified }) => (
<Button <>
variant="secondary" {isKebabified ? (
aria-label={i18n._(t`Delete`)} kebabedAdditionalControls()
onClick={toggleModal} ) : (
isDisabled={ <ToolbarGroup>
selected.length === 0 || selected.some(cannotDelete) <ToolbarItem>
} <Tooltip
> content={i18n._(
{i18n._(t`Delete`)} 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.`
</Button> )}
</div> position="top"
</Tooltip>, key="adhoc"
[ >
<Tooltip <AdHocCommandsButton
content={i18n._( css="margin-right: 20px"
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.` adHocItems={selected}
)} apiModule={InventoriesAPI}
position="top" itemId={parseInt(inventoryId, 10)}
key="adhoc" >
> {({ openAdHocCommands }) => (
<AdHocCommandsButton <Button
adHocItems={selected} variant="secondary"
apiModule={InventoriesAPI} aria-label={i18n._(t`Run command`)}
itemId={inventoryId} onClick={openAdHocCommands}
> isDisabled={groupCount === 0}
{({ openAdHocCommands }) => ( >
<Button {i18n._(t`Run command`)}
variant="secondary" </Button>
aria-label={i18n._(t`Run command`)} )}
onClick={openAdHocCommands} </AdHocCommandsButton>
isDisabled={ </Tooltip>
selected.length === 0 || selected.some(cannotDelete) </ToolbarItem>
} <ToolbarItem>
> <Tooltip
{i18n._(t`Run command`)} content={renderTooltip()}
</Button> 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 <AlertModal
isOpen={deletionError} isOpen={deletionError}
variant="error" variant="error"
aria-label={i18n._(t`deletion error`)}
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={() => setDeletionError(null)}
> >

View File

@@ -88,6 +88,10 @@ describe('<InventoryGroupsList />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => { test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroupsList').length).toBe(1); expect(wrapper.find('InventoryGroupsList').length).toBe(1);
@@ -143,15 +147,17 @@ describe('<InventoryGroupsList />', () => {
expect(el.props().checked).toBe(false); 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 () => { test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementation(() => InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error()) Promise.reject(new Error())
); );
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />); 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 () => { test('should show content error if groups are not successfully fetched from api', async () => {
@@ -159,26 +165,27 @@ describe('<InventoryGroupsList />', () => {
Promise.reject(new Error()) Promise.reject(new Error())
); );
await act(async () => { await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(); wrapper = mountWithContexts(<InventoryGroupsList />);
}); });
wrapper.update();
await act(async () => { await waitForElement(wrapper, 'ContentError', el => el.length > 0);
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);
}); });
test('should show error modal when group is not successfully deleted from api', async () => { 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( GroupsAPI.destroy.mockRejectedValue(
new Error({ new Error({
response: { 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 () => { await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(); wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')();
}); });
@@ -213,11 +239,14 @@ describe('<InventoryGroupsList />', () => {
}); });
await waitForElement( await waitForElement(
wrapper, wrapper,
'AlertModal[title="Error!"] Modal', 'AlertModal[aria-label="deletion error"] Modal',
el => el.props().isOpen === true && el.props().title === 'Error!' el => el.props().isOpen === true && el.props().title === 'Error!'
); );
await act(async () => { await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')(); wrapper
.find('AlertModal[aria-label="deletion error"]')
.invoke('onClose')();
}); });
}); });
}); });