Merge pull request #8243 from AlexSCorey/AdHocCommandsOnLists

Adds Ad Hoc Commands To Remaining Lists

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-10-07 21:55:14 +00:00
committed by GitHub
18 changed files with 848 additions and 548 deletions

View File

@@ -12,7 +12,9 @@ class Groups extends Base {
} }
associateHost(id, hostId) { associateHost(id, hostId) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId }); return this.http.post(`${this.baseUrl}${id}/hosts/`, {
id: hostId,
});
} }
createHost(id, data) { createHost(id, data) {
@@ -20,7 +22,9 @@ class Groups extends Base {
} }
readAllHosts(id, params) { readAllHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params }); return this.http.get(`${this.baseUrl}${id}/all_hosts/`, {
params,
});
} }
disassociateHost(id, host) { disassociateHost(id, host) {
@@ -29,6 +33,10 @@ class Groups extends Base {
disassociate: true, disassociate: true,
}); });
} }
readChildren(id, params) {
return this.http.get(`${this.baseUrl}${id}/children/`, params);
}
} }
export default Groups; export default Groups;

View File

@@ -1,19 +1,25 @@
import React, { useState, Fragment, useCallback, useEffect } from 'react'; import React, { 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 PropTypes from 'prop-types';
import useRequest, { useDismissableError } from '../../util/useRequest'; import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { CredentialTypesAPI } from '../../api';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard'; import AdHocCommandsWizard from './AdHocCommandsWizard';
import ContentLoading from '../ContentLoading'; import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) { function AdHocCommands({
const [isWizardOpen, setIsWizardOpen] = useState(false); onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
const history = useHistory(); const history = useHistory();
const verbosityOptions = [ const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
@@ -22,59 +28,26 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, { value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
]; ];
const {
error: fetchError,
request: fetchModuleOptions,
result: { moduleOptions, credentialTypeId },
} = useRequest(
useCallback(async () => {
const [choices, credId] = await Promise.all([
apiModule.readAdHocOptions(itemId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
const itemObject = (item, index) => {
return {
key: index,
value: item,
label: `${item}`,
isDisabled: false,
};
};
const options = choices.data.actions.GET.module_name.choices.map(
(choice, index) => itemObject(choice[0], index)
);
return {
moduleOptions: [itemObject('', -1), ...options],
credentialTypeId: credId.data.results[0].id,
};
}, [itemId, apiModule]),
{ moduleOptions: [] }
);
useEffect(() => {
fetchModuleOptions();
}, [fetchModuleOptions]);
const { const {
isloading: isLaunchLoading, isloading: isLaunchLoading,
error: launchError, error,
request: launchAdHocCommands, request: launchAdHocCommands,
} = useRequest( } = useRequest(
useCallback( useCallback(
async values => { async values => {
const { data } = await apiModule.launchAdHocCommands(itemId, values); const { data } = await InventoriesAPI.launchAdHocCommands(
itemId,
values
);
history.push(`/jobs/command/${data.id}/output`); history.push(`/jobs/command/${data.id}/output`);
}, },
[apiModule, itemId, history] [itemId, history]
) )
); );
const { error, dismissError } = useDismissableError( const { dismissError } = useDismissableError(error);
launchError || fetchError
);
const handleSubmit = async values => { const handleSubmit = async values => {
const { credential, ...remainingValues } = values; const { credential, ...remainingValues } = values;
@@ -85,14 +58,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
...remainingValues, ...remainingValues,
}; };
await launchAdHocCommands(manipulatedValues); await launchAdHocCommands(manipulatedValues);
setIsWizardOpen(false);
}; };
if (isLaunchLoading) { if (isLaunchLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (error && isWizardOpen) { if (error) {
return ( return (
<AlertModal <AlertModal
isOpen={error} isOpen={error}
@@ -100,43 +72,29 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => { onClose={() => {
dismissError(); dismissError();
setIsWizardOpen(false);
}} }}
> >
{launchError ? ( <>
<> {i18n._(t`Failed to launch job.`)}
{i18n._(t`Failed to launch job.`)} <ErrorDetail error={error} />
<ErrorDetail error={error} /> </>
</>
) : (
<ContentError error={error} />
)}
</AlertModal> </AlertModal>
); );
} }
return ( return (
<Fragment> <AdHocCommandsWizard
{children({ adHocItems={adHocItems}
openAdHocCommands: () => setIsWizardOpen(true), moduleOptions={moduleOptions}
})} verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
{isWizardOpen && ( onCloseWizard={onClose}
<AdHocCommandsWizard onLaunch={handleSubmit}
adHocItems={adHocItems} onDismissError={() => dismissError()}
moduleOptions={moduleOptions} />
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
)}
</Fragment>
); );
} }
AdHocCommands.propTypes = { AdHocCommands.propTypes = {
children: PropTypes.func.isRequired,
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired, itemId: PropTypes.number.isRequired,
}; };

View File

@@ -18,6 +18,10 @@ const credentials = [
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]; ];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [ const adHocItems = [
{ {
name: 'Inventory 1 Org 0', name: 'Inventory 1 Org 0',
@@ -25,10 +29,6 @@ const adHocItems = [
{ name: 'Inventory 2 Org 0' }, { name: 'Inventory 2 Org 0' },
]; ];
const children = ({ openAdHocCommands }) => (
<button type="submit" onClick={() => openAdHocCommands()} />
);
describe('<AdHocCommands />', () => { describe('<AdHocCommands />', () => {
let wrapper; let wrapper;
afterEach(() => { afterEach(() => {
@@ -40,111 +40,38 @@ describe('<AdHocCommands />', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1} itemId={1}
credentialTypeId={1} credentialTypeId={1}
> adHocItems={adHocItems}
{children} moduleOptions={moduleOptions}
</AdHocCommands> />
); );
}); });
expect(wrapper.find('AdHocCommands').length).toBe(1); expect(wrapper.find('AdHocCommands').length).toBe(1);
}); });
test('calls api on Mount', async () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
expect(InventoriesAPI.readAdHocOptions).toBeCalledWith(1);
expect(CredentialTypesAPI.read).toBeCalledWith({ namespace: 'ssh' });
});
test('should open the wizard', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('should submit properly', async () => { test('should submit properly', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({ InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {
results: credentials, results: credentials,
count: 5, count: 5,
}, },
}); });
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1} itemId={1}
credentialTypeId={1} credentialTypeId={1}
> adHocItems={adHocItems}
{children} moduleOptions={moduleOptions}
</AdHocCommands> />
); );
}); });
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
@@ -177,6 +104,7 @@ describe('<AdHocCommands />', () => {
); );
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
// second step of wizard // second step of wizard
await act(async () => { await act(async () => {
wrapper wrapper
.find('input[aria-labelledby="check-action-item-4"]') .find('input[aria-labelledby="check-action-item-4"]')
@@ -205,10 +133,6 @@ describe('<AdHocCommands />', () => {
module_name: 'command', module_name: 'command',
verbosity: 1, verbosity: 1,
}); });
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
}); });
test('should throw error on submission properly', async () => { test('should throw error on submission properly', async () => {
@@ -251,16 +175,15 @@ describe('<AdHocCommands />', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<AdHocCommands <AdHocCommands
apiModule={InventoriesAPI} css="margin-right: 20px"
adHocItems={adHocItems} onClose={() => {}}
itemId={1}
credentialTypeId={1} credentialTypeId={1}
> itemId={1}
{children} adHocItems={adHocItems}
</AdHocCommands> moduleOptions={moduleOptions}
/>
); );
}); });
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update(); wrapper.update();
@@ -312,36 +235,6 @@ describe('<AdHocCommands />', () => {
wrapper.find('Button[type="submit"]').prop('onClick')() wrapper.find('Button[type="submit"]').prop('onClick')()
); );
waitForElement(wrapper, 'ErrorDetail', el => el.length > 0); await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
});
test('should open alert modal when error on fetching data', async () => {
InventoriesAPI.readAdHocOptions.mockRejectedValue(
new Error({
response: {
config: {
method: 'options',
url: '/api/v2/inventories/1/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
}); });
}); });

View File

@@ -1,13 +1,26 @@
import React, { useState } from 'react'; 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 { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import { Tooltip } from '@patternfly/react-core';
import { withFormik, useFormikContext } from 'formik'; import { withFormik, useFormikContext } from 'formik';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import Wizard from '../Wizard'; import Wizard from '../Wizard';
import AdHocCredentialStep from './AdHocCredentialStep'; import AdHocCredentialStep from './AdHocCredentialStep';
import AdHocDetailsStep from './AdHocDetailsStep'; import AdHocDetailsStep from './AdHocDetailsStep';
const AlertText = styled.div`
color: var(--pf-global--danger-color--200);
font-weight: var(--pf-global--FontWeight--bold);
`;
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
color: var(--pf-global--danger-color--100);
`;
function AdHocCommandsWizard({ function AdHocCommandsWizard({
onLaunch, onLaunch,
i18n, i18n,
@@ -19,7 +32,7 @@ function AdHocCommandsWizard({
const [currentStepId, setCurrentStepId] = useState(1); const [currentStepId, setCurrentStepId] = useState(1);
const [enableLaunch, setEnableLaunch] = useState(false); const [enableLaunch, setEnableLaunch] = useState(false);
const { values } = useFormikContext(); const { values, errors, touched } = useFormikContext();
const enabledNextOnDetailsStep = () => { const enabledNextOnDetailsStep = () => {
if (!values.module_name) { if (!values.module_name) {
@@ -36,11 +49,26 @@ function AdHocCommandsWizard({
} }
return undefined; // makes the linter happy; return undefined; // makes the linter happy;
}; };
const hasDetailsStepError = errors.module_args && touched.module_args;
const steps = [ const steps = [
{ {
id: 1, id: 1,
key: 1, key: 1,
name: i18n._(t`Details`), name: hasDetailsStepError ? (
<AlertText>
{i18n._(t`Details`)}
<Tooltip
position="right"
content={i18n._(t`This step contains errors`)}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</AlertText>
) : (
i18n._(t`Details`)
),
component: ( component: (
<AdHocDetailsStep <AdHocDetailsStep
moduleOptions={moduleOptions} moduleOptions={moduleOptions}
@@ -60,7 +88,7 @@ function AdHocCommandsWizard({
onEnableLaunch={() => setEnableLaunch(true)} onEnableLaunch={() => setEnableLaunch(true)}
/> />
), ),
enableNext: enableLaunch, enableNext: enableLaunch && Object.values(errors).length === 0,
nextButtonText: i18n._(t`Launch`), nextButtonText: i18n._(t`Launch`),
canJumpTo: currentStepId >= 2, canJumpTo: currentStepId >= 2,
}, },

View File

@@ -148,6 +148,20 @@ describe('<AdHocCommandsWizard/>', () => {
expect(onLaunch).toHaveBeenCalled(); expect(onLaunch).toHaveBeenCalled();
}); });
test('should show error in navigation bar', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
await act(async () => {
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
{},
'command'
);
wrapper.find('input#module_args').simulate('change', {
target: { value: '', name: 'module_args' },
});
});
waitForElement(wrapper, 'ExclamationCircleIcon', el => el.length > 0);
});
test('expect credential step to throw error', async () => { test('expect credential step to throw error', async () => {
CredentialsAPI.read.mockRejectedValue( CredentialsAPI.read.mockRejectedValue(

View File

@@ -65,6 +65,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
<FormGroup <FormGroup
fieldId="credential" fieldId="credential"
label={i18n._(t`Machine Credential`)} label={i18n._(t`Machine Credential`)}
aria-label={i18n._(t`Machine Credential`)}
isRequired isRequired
validated={ validated={
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error' !credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'

View File

@@ -27,32 +27,43 @@ const TooltipWrapper = styled.div`
// in failing tests. // in failing tests.
const brandName = BrandName; const brandName = BrandName;
function CredentialStep({ i18n, verbosityOptions, moduleOptions }) { function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
const [module_nameField, module_nameMeta, module_nameHelpers] = useField({ const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
name: 'module_name', name: 'module_name',
validate: required(null, i18n), validate: required(null, i18n),
}); });
const [variablesField] = useField('extra_vars'); const [variablesField] = useField('extra_vars');
const [diff_modeField, , diff_modeHelpers] = useField('diff_mode'); const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [become_enabledField, , become_enabledHelpers] = useField( const [becomeEnabledField, , becomeEnabledHelpers] = useField(
'become_enabled' 'become_enabled'
); );
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({ const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
name: 'verbosity', name: 'verbosity',
validate: required(null, i18n), validate: required(null, i18n),
}); });
const argumentsRequired =
moduleNameField.value === 'command' || moduleNameField.value === 'shell';
const [, argumentsMeta, argumentsHelpers] = useField({
name: 'module_args',
validate: argumentsRequired && required(null, i18n),
});
const isValid = !argumentsMeta.error || !argumentsMeta.touched;
return ( return (
<Form> <Form>
<FormColumnLayout> <FormColumnLayout>
<FormFullWidthLayout> <FormFullWidthLayout>
<FormGroup <FormGroup
fieldId="module_name" fieldId="module_name"
aria-label={i18n._(t`Module`)}
label={i18n._(t`Module`)} label={i18n._(t`Module`)}
isRequired isRequired
helperTextInvalid={module_nameMeta.error} helperTextInvalid={moduleNameMeta.error}
validated={ validated={
!module_nameMeta.touched || !module_nameMeta.error !moduleNameMeta.touched || !moduleNameMeta.error
? 'default' ? 'default'
: 'error' : 'error'
} }
@@ -65,33 +76,52 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
} }
> >
<AnsibleSelect <AnsibleSelect
{...module_nameField} {...moduleNameField}
isValid={!module_nameMeta.touched || !module_nameMeta.error} placeHolder={i18n._(t`Select a module`)}
isValid={!moduleNameMeta.touched || !moduleNameMeta.error}
id="module_name" id="module_name"
data={moduleOptions || []} data={[
{
value: '',
key: '',
label: i18n._(t`Choose a module`),
isDisabled: true,
},
...moduleOptions.map(value => ({
value: value[0],
label: value[0],
key: value[0],
})),
]}
onChange={(event, value) => { onChange={(event, value) => {
module_nameHelpers.setValue(value); if (value !== 'command' && value !== 'shell') {
argumentsHelpers.setTouched(false);
}
moduleNameHelpers.setValue(value);
}} }}
/> />
</FormGroup> </FormGroup>
<FormField <FormField
id="module_args" id="module_args"
name="module_args" name="module_args"
aria-label={i18n._(t`Arguments`)}
type="text" type="text"
label={i18n._(t`Arguments`)} label={i18n._(t`Arguments`)}
validate={required(null, i18n)} validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)}
placeholder={i18n._(t`Enter arguments`)}
isRequired={ isRequired={
module_nameField.value === 'command' || moduleNameField.value === 'command' ||
module_nameField.value === 'shell' moduleNameField.value === 'shell'
} }
tooltip={ tooltip={
module_nameField.value ? ( moduleNameField.value ? (
<> <>
{i18n._( {i18n._(
t`These arguments are used with the specified module. You can find information about ${module_nameField.value} by clicking ` t`These arguments are used with the specified module. You can find information about ${moduleNameField.value} by clicking `
)} )}
<a <a
href={`https://docs.ansible.com/ansible/latest/modules/${module_nameField.value}_module.html`} href={`https://docs.ansible.com/ansible/latest/modules/${moduleNameField.value}_module.html`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -106,6 +136,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
/> />
<FormGroup <FormGroup
fieldId="verbosity" fieldId="verbosity"
aria-label={i18n._(t`Verbosity`)}
label={i18n._(t`Verbosity`)} label={i18n._(t`Verbosity`)}
isRequired isRequired
validated={ validated={
@@ -137,6 +168,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
name="limit" name="limit"
type="text" type="text"
label={i18n._(t`Limit`)} label={i18n._(t`Limit`)}
aria-label={i18n._(t`Limit`)}
tooltip={ tooltip={
<span> <span>
{i18n._( {i18n._(
@@ -158,6 +190,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
type="number" type="number"
min="0" min="0"
label={i18n._(t`Forks`)} label={i18n._(t`Forks`)}
aria-label={i18n._(t`Forks`)}
tooltip={ tooltip={
<span> <span>
{i18n._( {i18n._(
@@ -176,6 +209,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
<FormColumnLayout> <FormColumnLayout>
<FormGroup <FormGroup
label={i18n._(t`Show changes`)} label={i18n._(t`Show changes`)}
aria-label={i18n._(t`Show changes`)}
labelIcon={ labelIcon={
<FieldTooltip <FieldTooltip
content={i18n._( content={i18n._(
@@ -189,9 +223,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
id="diff_mode" id="diff_mode"
label={i18n._(t`On`)} label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)} labelOff={i18n._(t`Off`)}
isChecked={diff_modeField.value} isChecked={diffModeField.value}
onChange={() => { onChange={() => {
diff_modeHelpers.setValue(!diff_modeField.value); diffModeHelpers.setValue(!diffModeField.value);
}} }}
aria-label={i18n._(t`toggle changes`)} aria-label={i18n._(t`toggle changes`)}
/> />
@@ -222,9 +256,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</span> </span>
} }
id="become_enabled" id="become_enabled"
isChecked={become_enabledField.value} isChecked={becomeEnabledField.value}
onChange={checked => { onChange={checked => {
become_enabledHelpers.setValue(checked); becomeEnabledHelpers.setValue(checked);
}} }}
/> />
</FormCheckboxLayout> </FormCheckboxLayout>
@@ -273,6 +307,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</TooltipWrapper> </TooltipWrapper>
} }
label={i18n._(t`Extra variables`)} label={i18n._(t`Extra variables`)}
aria-label={i18n._(t`Extra variables`)}
/> />
</FormFullWidthLayout> </FormFullWidthLayout>
</FormColumnLayout> </FormColumnLayout>
@@ -280,9 +315,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
); );
} }
CredentialStep.propTypes = { AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired, moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
}; };
export default withI18n()(CredentialStep); export default withI18n()(AdHocDetailsStep);

View File

@@ -12,9 +12,8 @@ const verbosityOptions = [
{ key: 1, value: 1, label: '1', isDisabled: false }, { key: 1, value: 1, label: '1', isDisabled: false },
]; ];
const moduleOptions = [ const moduleOptions = [
{ key: -1, value: '', label: '', isDisabled: false }, ['command', 'command'],
{ key: 0, value: 'command', label: 'command', isDisabled: false }, ['shell', 'shell'],
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
]; ];
const onLimitChange = jest.fn(); const onLimitChange = jest.fn();
const initialValues = { const initialValues = {

View File

@@ -2,8 +2,14 @@ import React, { useEffect, useCallback, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom'; import { useHistory, useLocation, useParams } 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,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs'; import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
@@ -16,6 +22,8 @@ import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal'; import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton'; import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown'; import AddHostDropdown from './AddHostDropdown';
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('host', {
function InventoryGroupHostList({ i18n }) { function InventoryGroupHostList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { id: inventoryId, groupId } = useParams(); const { id: inventoryId, groupId } = useParams();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
@@ -38,6 +47,9 @@ function InventoryGroupHostList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -45,9 +57,16 @@ function InventoryGroupHostList({ i18n }) {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([ const [
response,
actionsResponse,
adHocOptions,
cred,
] = await Promise.all([
GroupsAPI.readAllHosts(groupId, params), GroupsAPI.readAllHosts(groupId, params),
InventoriesAPI.readHostsOptions(inventoryId), InventoriesAPI.readHostsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
@@ -60,6 +79,9 @@ function InventoryGroupHostList({ i18n }) {
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
}; };
}, [groupId, inventoryId, location.search]), }, [groupId, inventoryId, location.search]),
{ {
@@ -68,6 +90,8 @@ function InventoryGroupHostList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -195,6 +219,40 @@ function InventoryGroupHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it.
The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}
@@ -222,6 +280,7 @@ function InventoryGroupHostList({ i18n }) {
emptyStateControls={ emptyStateControls={
canAdd && ( canAdd && (
<AddHostDropdown <AddHostDropdown
key="associate"
onAddExisting={() => setIsModalOpen(true)} onAddExisting={() => setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)} onAddNew={() => history.push(addFormUrl)}
/> />
@@ -239,6 +298,16 @@ function InventoryGroupHostList({ i18n }) {
title={i18n._(t`Select Hosts`)} title={i18n._(t`Select Hosts`)}
/> />
)} )}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{associateError && ( {associateError && (
<AlertModal <AlertModal
isOpen={associateError} isOpen={associateError}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -11,6 +11,7 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api/models/Groups'); jest.mock('../../../api/models/Groups');
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/CredentialTypes');
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useParams: () => ({ useParams: () => ({
@@ -34,6 +35,17 @@ describe('<InventoryGroupHostList />', () => {
}, },
}, },
}); });
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />); wrapper = mountWithContexts(<InventoryGroupHostList />);
}); });
@@ -95,6 +107,29 @@ describe('<InventoryGroupHostList />', () => {
}); });
}); });
test('should render enabled ad hoc commands button', async () => {
GroupsAPI.readAllHosts.mockResolvedValue({
data: { ...mockHosts },
});
InventoriesAPI.readHostsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
test('should show add dropdown button according to permissions', async () => { test('should show add dropdown button according to permissions', async () => {
expect(wrapper.find('AddHostDropdown').length).toBe(1); expect(wrapper.find('AddHostDropdown').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({

View File

@@ -12,7 +12,7 @@ import {
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';
import { InventoriesAPI, GroupsAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
@@ -22,7 +22,8 @@ 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 AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import { Kebabified } from '../../../contexts/Kebabified'; import { Kebabified } from '../../../contexts/Kebabified';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -51,6 +52,7 @@ const useModal = () => {
function InventoryGroupsList({ i18n }) { function InventoryGroupsList({ i18n }) {
const [deletionError, setDeletionError] = useState(null); const [deletionError, setDeletionError] = useState(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const { isModalOpen, toggleModal } = useModal(); const { isModalOpen, toggleModal } = useModal();
const { id: inventoryId } = useParams(); const { id: inventoryId } = useParams();
@@ -62,27 +64,36 @@ function InventoryGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchGroups, request: fetchData,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([ const [response, groupOptions, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readGroups(inventoryId, params), InventoriesAPI.readGroups(inventoryId, params),
InventoriesAPI.readGroupsOptions(inventoryId), InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
groups: response.data.results, groups: response.data.results,
groupCount: response.data.count, groupCount: response.data.count,
actions: actionsResponse.data.actions, actions: groupOptions.data.actions,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] groupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} groupOptions.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => groupOptions.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
}; };
}, [inventoryId, location]), }, [inventoryId, location]),
{ {
@@ -95,8 +106,8 @@ function InventoryGroupsList({ i18n }) {
); );
useEffect(() => { useEffect(() => {
fetchGroups(); fetchData();
}, [fetchGroups]); }, [fetchData]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected( const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups groups
@@ -144,7 +155,7 @@ function InventoryGroupsList({ i18n }) {
} }
toggleModal(); toggleModal();
fetchGroups(); fetchData();
setSelected([]); setSelected([]);
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
@@ -153,21 +164,14 @@ function InventoryGroupsList({ i18n }) {
const kebabedAdditionalControls = () => { const kebabedAdditionalControls = () => {
return ( return (
<> <>
<AdHocCommandsButton <DropdownItem
adHocItems={selected} key="run command"
apiModule={InventoriesAPI} onClick={() => setIsAdHocCommandsOpen(true)}
itemId={parseInt(inventoryId, 10)} isDisabled={groupCount === 0 || isAdHocDisabled}
> >
{({ openAdHocCommands }) => ( {i18n._(t`Run command`)}
<DropdownItem </DropdownItem>
key="run command"
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
<DropdownItem <DropdownItem
variant="danger" variant="danger"
aria-label={i18n._(t`Delete`)} aria-label={i18n._(t`Delete`)}
@@ -264,23 +268,14 @@ function InventoryGroupsList({ i18n }) {
position="top" position="top"
key="adhoc" key="adhoc"
> >
<AdHocCommandsButton <Button
css="margin-right: 20px" variant="secondary"
adHocItems={selected} aria-label={i18n._(t`Run command`)}
apiModule={InventoriesAPI} onClick={() => setIsAdHocCommandsOpen(true)}
itemId={parseInt(inventoryId, 10)} isDisabled={groupCount === 0 || isAdHocDisabled}
> >
{({ openAdHocCommands }) => ( {i18n._(t`Run command`)}
<Button </Button>
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip> </Tooltip>
</ToolbarItem> </ToolbarItem>
<ToolbarItem> <ToolbarItem>
@@ -321,6 +316,16 @@ function InventoryGroupsList({ i18n }) {
) )
} }
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { InventoriesAPI, GroupsAPI } from '../../../api'; import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import InventoryGroupsList from './InventoryGroupsList'; import InventoryGroupsList from './InventoryGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -71,6 +71,17 @@ describe('<InventoryGroupsList />', () => {
}, },
}, },
}); });
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/3/groups'],
}); });
@@ -147,31 +158,17 @@ describe('<InventoryGroupsList />', () => {
expect(el.props().checked).toBe(false); expect(el.props().checked).toBe(false);
}); });
}); });
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
}); });
describe('<InventoryGroupsList/> error handling', () => { describe('<InventoryGroupsList/> error handling', () => {
let wrapper; let wrapper;
test('should show content error when api throws error on initial render', async () => { beforeEach(() => {
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
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({ InventoriesAPI.readGroups.mockResolvedValue({
data: { data: {
count: mockGroups.length, count: mockGroups.length,
@@ -197,7 +194,42 @@ describe('<InventoryGroupsList/> error handling', () => {
}, },
}) })
); );
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show error modal when group is not successfully deleted from api', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/3/groups'],
}); });
@@ -249,4 +281,27 @@ describe('<InventoryGroupsList/> error handling', () => {
.invoke('onClose')(); .invoke('onClose')();
}); });
}); });
test('should render disabled ad hoc button', async () => {
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 } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper.find('button[aria-label="Run command"]').prop('disabled')
).toBe(true);
});
}); });

View File

@@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } 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,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import useRequest, { import useRequest, {
useDismissableError, useDismissableError,
useDeleteItems, useDeleteItems,
} from '../../../util/useRequest'; } from '../../../util/useRequest';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import { HostsAPI, InventoriesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -17,6 +23,8 @@ import PaginatedDataList, {
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal'; import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton'; import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostGroupItem from './InventoryHostGroupItem'; import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('group', {
function InventoryHostGroupsList({ i18n }) { function InventoryHostGroupsList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { hostId, id: invId } = useParams(); const { hostId, id: invId } = useParams();
const { search } = useLocation(); const { search } = useLocation();
@@ -37,6 +46,9 @@ function InventoryHostGroupsList({ i18n }) {
actions, actions,
relatedSearchableKeys, relatedSearchableKeys,
searchableKeys, searchableKeys,
moduleOptions,
isAdHocDisabled,
credentialTypeId,
}, },
error: contentError, error: contentError,
isLoading, isLoading,
@@ -49,22 +61,29 @@ function InventoryHostGroupsList({ i18n }) {
{ {
data: { count, results }, data: { count, results },
}, },
actionsResponse, hostGroupOptions,
adHocOptions,
cred,
] = await Promise.all([ ] = await Promise.all([
HostsAPI.readAllGroups(hostId, params), HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId), HostsAPI.readGroupsOptions(hostId),
InventoriesAPI.readAdHocOptions(invId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]); ]);
return { return {
groups: results, groups: results,
itemCount: count, itemCount: count,
actions: actionsResponse.data.actions, actions: hostGroupOptions.data.actions,
relatedSearchableKeys: ( relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || [] hostGroupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)), ).map(val => val.slice(0, -8)),
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} hostGroupOptions.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => hostGroupOptions.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
}; };
}, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps
{ {
@@ -73,6 +92,8 @@ function InventoryHostGroupsList({ i18n }) {
actions: {}, actions: {},
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -201,6 +222,40 @@ function InventoryHostGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<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 host, a selection of multiple hosts, or a selection of multiple groups.`
)}
position="top"
key="adhoc"
>
<Button
key="run command"
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}
@@ -208,8 +263,8 @@ function InventoryHostGroupsList({ i18n }) {
modalTitle={i18n._(t`Disassociate group from host?`)} modalTitle={i18n._(t`Disassociate group from host?`)}
modalNote={i18n._(t` modalNote={i18n._(t`
Note that you may still see the group in the list after Note that you may still see the group in the list after
disassociating if the host is also a member of that groups disassociating if the host is also a member of that groups
children. This list shows all groups the host is associated children. This list shows all groups the host is associated
with directly and indirectly. with directly and indirectly.
`)} `)}
/>, />,
@@ -233,6 +288,16 @@ function InventoryHostGroupsList({ i18n }) {
title={i18n._(t`Select Groups`)} title={i18n._(t`Select Groups`)}
/> />
)} )}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(invId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import { HostsAPI, InventoriesAPI } from '../../../api'; import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import InventoryHostGroupsList from './InventoryHostGroupsList'; import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -80,6 +80,17 @@ describe('<InventoryHostGroupsList />', () => {
}, },
}, },
}); });
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'], initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
}); });
@@ -272,4 +283,11 @@ describe('<InventoryHostGroupsList />', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1); expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
}); });
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
}); });

View File

@@ -1,10 +1,16 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } 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,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI, HostsAPI } from '../../../api'; import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
@@ -12,6 +18,8 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem'; import InventoryHostItem from './InventoryHostItem';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
@@ -21,48 +29,64 @@ const QS_CONFIG = getQSConfig('host', {
}); });
function InventoryHostList({ i18n }) { function InventoryHostList({ i18n }) {
const [actions, setActions] = useState(null); const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [hostCount, setHostCount] = useState(0);
const [hosts, setHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const { id } = useParams(); const { id } = useParams();
const { search } = useLocation(); const { search } = useLocation();
const fetchHosts = (hostId, queryString) => { const {
const params = parseQueryString(QS_CONFIG, queryString); result: {
return InventoriesAPI.readHosts(hostId, params); hosts,
}; hostCount,
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
},
error: contentError,
isLoading,
request: fetchData,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search);
const [response, hostOptions, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readHosts(id, params),
InventoriesAPI.readHostsOptions(id),
InventoriesAPI.readAdHocOptions(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
hosts: response.data.results,
hostCount: response.data.count,
actions: hostOptions.data.actions,
relatedSearchableKeys: (
hostOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter(
key => hostOptions.data.actions?.GET[key].filterable
),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [id, search]),
{
hosts: [],
hostCount: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
useEffect(() => { useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchHosts(id, search),
InventoriesAPI.readOptions(),
]);
setHosts(results);
setHostCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData(); fetchData();
}, [id, search]); }, [fetchData]);
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []); setSelected(isSelected ? [...hosts] : []);
@@ -75,30 +99,17 @@ function InventoryHostList({ i18n }) {
setSelected(selected.concat(row)); setSelected(selected.concat(row));
} }
}; };
const {
const handleDelete = async () => { isLoading: isDeleteLoading,
setIsLoading(true); deleteItems: deleteHosts,
deletionError,
try { clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
await Promise.all(selected.map(host => HostsAPI.destroy(host.id))); await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
} catch (error) { }, [selected]),
setDeletionError(error); { qsConfig: QS_CONFIG, fetchItems: fetchData }
} finally { );
setSelected([]);
try {
const {
data: { count, results },
} = await fetchHosts(id, search);
setHosts(results);
setHostCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
};
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@@ -108,7 +119,7 @@ function InventoryHostList({ i18n }) {
<> <>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading} hasContentLoading={isLoading || isDeleteLoading}
items={hosts} items={hosts}
itemCount={hostCount} itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
@@ -133,6 +144,8 @@ function InventoryHostList({ i18n }) {
isNumeric: true, isNumeric: true,
}, },
]} ]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => ( renderToolbar={props => (
<DataListToolbar <DataListToolbar
{...props} {...props}
@@ -149,9 +162,43 @@ function InventoryHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
aria-label={i18n._(t`Run command`)}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={handleDelete} onDelete={deleteHosts}
itemsToDelete={selected} itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
/>, />,
@@ -177,12 +224,22 @@ function InventoryHostList({ i18n }) {
) )
} }
/> />
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
itemId={id}
/>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}
variant="error" variant="error"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more hosts.`)} {i18n._(t`Failed to delete one or more hosts.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoriesAPI, HostsAPI } from '../../../api'; import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -85,7 +85,7 @@ describe('<InventoryHostList />', () => {
results: mockHosts, results: mockHosts,
}, },
}); });
InventoriesAPI.readOptions.mockResolvedValue({ InventoriesAPI.readHostsOptions.mockResolvedValue({
data: { data: {
actions: { actions: {
GET: {}, GET: {},
@@ -93,6 +93,17 @@ describe('<InventoryHostList />', () => {
}, },
}, },
}); });
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHostList />); wrapper = mountWithContexts(<InventoryHostList />);
}); });
@@ -265,8 +276,15 @@ describe('<InventoryHostList />', () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1); expect(wrapper.find('ToolbarAddButton').length).toBe(1);
}); });
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
test('should hide Add button for users without ability to POST', async () => { test('should hide Add button for users without ability to POST', async () => {
InventoriesAPI.readOptions.mockResolvedValueOnce({ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: { data: {
actions: { actions: {
GET: {}, GET: {},
@@ -283,7 +301,7 @@ describe('<InventoryHostList />', () => {
}); });
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.readOptions.mockImplementation(() => InventoriesAPI.readHostsOptions.mockImplementation(() =>
Promise.reject(new Error()) Promise.reject(new Error())
); );
await act(async () => { await act(async () => {

View File

@@ -1,16 +1,23 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { 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 } from '@patternfly/react-core'; import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList'; import PaginatedDataList from '../../../components/PaginatedDataList';
import SmartInventoryHostListItem from './SmartInventoryHostListItem'; import SmartInventoryHostListItem from './SmartInventoryHostListItem';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import useSelected from '../../../util/useSelected'; import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,
@@ -20,24 +27,35 @@ const QS_CONFIG = getQSConfig('host', {
function SmartInventoryHostList({ i18n, inventory }) { function SmartInventoryHostList({ i18n, inventory }) {
const location = useLocation(); const location = useLocation();
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { const {
result: { hosts, count }, result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchHosts, request: fetchHosts,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, location.search);
const { data } = await InventoriesAPI.readHosts(inventory.id, params); const [hostResponse, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readHosts(inventory.id, params),
InventoriesAPI.readAdHocOptions(inventory.id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return { return {
hosts: data.results, hosts: hostResponse.data.results,
count: data.count, count: hostResponse.data.count,
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
}; };
}, [location.search, inventory.id]), }, [location.search, inventory.id]),
{ {
hosts: [], hosts: [],
count: 0, count: 0,
moduleOptions: [],
isAdHocDisabled: true,
} }
); );
@@ -50,66 +68,106 @@ function SmartInventoryHostList({ i18n, inventory }) {
}, [fetchHosts]); }, [fetchHosts]);
return ( return (
<PaginatedDataList <>
contentError={contentError} <PaginatedDataList
hasContentLoading={isLoading} contentError={contentError}
items={hosts} hasContentLoading={isLoading}
itemCount={count} items={hosts}
pluralizedItemName={i18n._(t`Hosts`)} itemCount={count}
qsConfig={QS_CONFIG} pluralizedItemName={i18n._(t`Hosts`)}
onRowClick={handleSelect} qsConfig={QS_CONFIG}
toolbarSearchColumns={[ onRowClick={handleSelect}
{ toolbarSearchColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
isDefault: true, key: 'name',
}, isDefault: true,
{ },
name: i18n._(t`Created by (username)`), {
key: 'created_by__username', name: i18n._(t`Created by (username)`),
}, key: 'created_by__username',
{ },
name: i18n._(t`Modified by (username)`), {
key: 'modified_by__username', name: i18n._(t`Modified by (username)`),
}, key: 'modified_by__username',
]} },
toolbarSortColumns={[ ]}
{ toolbarSortColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
}, key: 'name',
]} },
renderToolbar={props => ( ]}
<DataListToolbar renderToolbar={props => (
{...props} <DataListToolbar
showSelectAll {...props}
isAllSelected={isAllSelected} showSelectAll
onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])} isAllSelected={isAllSelected}
qsConfig={QS_CONFIG} onSelectAll={isSelected =>
additionalControls={ setSelected(isSelected ? [...hosts] : [])
inventory?.summary_fields?.user_capabilities?.adhoc }
? [ qsConfig={QS_CONFIG}
<Button additionalControls={
aria-label={i18n._(t`Run commands`)} inventory?.summary_fields?.user_capabilities?.adhoc
isDisabled={selected.length === 0} ? [
> <Kebabified>
{i18n._(t`Run commands`)} {({ isKebabified }) =>
</Button>, isKebabified ? (
] <DropdownItem
: [] aria-label={i18n._(t`Run command`)}
} onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
]
: []
}
/>
)}
renderItem={host => (
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventory.id, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/> />
)} )}
renderItem={host => ( </>
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
); );
} }

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -12,125 +12,109 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api'); jest.mock('../../../api');
describe('<SmartInventoryHostList />', () => { describe('<SmartInventoryHostList />', () => {
describe('User has adhoc permissions', () => { let wrapper;
let wrapper; const clonedInventory = {
const clonedInventory = { ...mockInventory,
...mockInventory, summary_fields: {
summary_fields: { ...mockInventory.summary_fields,
...mockInventory.summary_fields, user_capabilities: {
user_capabilities: { ...mockInventory.summary_fields.user_capabilities,
...mockInventory.summary_fields.user_capabilities, },
},
};
beforeAll(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts,
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
}, },
}, },
};
beforeAll(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts,
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
CredentialTypesAPI.read.mockResolvedValue({
afterAll(() => { data: { count: 1, results: [{ id: 1, name: 'cred' }] },
jest.clearAllMocks();
wrapper.unmount();
}); });
await act(async () => {
test('initially renders successfully', () => { wrapper = mountWithContexts(
expect(wrapper.find('SmartInventoryHostList').length).toBe(1); <SmartInventoryHostList inventory={clonedInventory} />
});
test('should fetch hosts from api and render them in the list', () => {
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
});
test('should disable run commands button when no hosts are selected', () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
); );
expect(runCommandsButton.length).toBe(1);
expect(runCommandsButton.prop('disabled')).toEqual(true);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('should enable run commands button when at least one host is selected', () => { afterAll(() => {
act(() => { jest.clearAllMocks();
wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')( wrapper.unmount();
true });
);
}); test('initially renders successfully', () => {
wrapper.update(); expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
const runCommandsButton = wrapper.find( });
'button[aria-label="Run commands"]'
); test('should fetch hosts from api and render them in the list', () => {
expect(runCommandsButton.prop('disabled')).toEqual(false); expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
});
test('should have run command button', () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
}); });
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
expect(runCommandsButton.length).toBe(1);
expect(runCommandsButton.prop('disabled')).toBe(false);
});
test('should select and deselect all items', async () => { test('should select and deselect all items', async () => {
act(() => { act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(true); wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(true);
});
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(false);
});
}); });
wrapper.update();
test('should show content error when api throws an error', async () => { wrapper.find('DataListCheck').forEach(el => {
InventoriesAPI.readHosts.mockImplementation(() => expect(el.props().checked).toEqual(true);
Promise.reject(new Error()) });
); act(() => {
await act(async () => { wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
wrapper = mountWithContexts( });
<SmartInventoryHostList inventory={mockInventory} /> wrapper.update();
); wrapper.find('DataListCheck').forEach(el => {
}); expect(el.props().checked).toEqual(false);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });
describe('User does not have adhoc permissions', () => { test('should show content error when api throws an error', async () => {
let wrapper; InventoriesAPI.readHosts.mockImplementation(() =>
const clonedInventory = { Promise.reject(new Error())
...mockInventory, );
summary_fields: { await act(async () => {
user_capabilities: { wrapper = mountWithContexts(
adhoc: false, <SmartInventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should disable run commands button', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
}, },
}, },
};
test('should hide run commands button', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.length).toBe(0);
jest.clearAllMocks();
wrapper.unmount();
}); });
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
}); });
}); });