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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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