Adds Ad Hoc Commands

This commit is contained in:
Alex Corey
2020-09-25 15:06:07 -04:00
parent 0b824ee058
commit eb2d7c6a77
12 changed files with 538 additions and 150 deletions

View File

@@ -25,7 +25,7 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
const { const {
error: fetchError, error: fetchError,
request: fetchModuleOptions, request: fetchModuleOptions,
result: { moduleOptions, credentialTypeId }, result: { moduleOptions, credentialTypeId, isDisabled },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const [choices, credId] = await Promise.all([ const [choices, credId] = await Promise.all([
@@ -44,13 +44,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
const options = choices.data.actions.GET.module_name.choices.map( const options = choices.data.actions.GET.module_name.choices.map(
(choice, index) => itemObject(choice[0], index) (choice, index) => itemObject(choice[0], index)
); );
return { return {
moduleOptions: [itemObject('', -1), ...options], moduleOptions: [itemObject('', -1), ...options],
credentialTypeId: credId.data.results[0].id, credentialTypeId: credId.data.results[0].id,
isDisabled: !choices.data.actions.POST,
}; };
}, [itemId, apiModule]), }, [itemId, apiModule]),
{ moduleOptions: [] } { moduleOptions: [], isDisabled: true }
); );
useEffect(() => { useEffect(() => {
@@ -118,6 +118,7 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
<Fragment> <Fragment>
{children({ {children({
openAdHocCommands: () => setIsWizardOpen(true), openAdHocCommands: () => setIsWizardOpen(true),
isDisabled,
})} })}
{isWizardOpen && ( {isWizardOpen && (

View File

@@ -25,8 +25,12 @@ const adHocItems = [
{ name: 'Inventory 2 Org 0' }, { name: 'Inventory 2 Org 0' },
]; ];
const children = ({ openAdHocCommands }) => ( const children = ({ openAdHocCommands, isDisabled }) => (
<button type="submit" onClick={() => openAdHocCommands()} /> <button
type="submit"
disabled={isDisabled}
onClick={() => openAdHocCommands()}
/>
); );
describe('<AdHocCommands />', () => { describe('<AdHocCommands />', () => {
@@ -344,4 +348,26 @@ describe('<AdHocCommands />', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1); expect(wrapper.find('ErrorDetail').length).toBe(1);
}); });
test('should disable button', async () => {
const isDisabled = true;
const newChild = ({ openAdHocCommands }) => (
<button
type="submit"
disabled={isDisabled}
onClick={() => openAdHocCommands()}
/>
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{newChild}
</AdHocCommands>
);
});
});
}); });

View File

@@ -2,6 +2,12 @@ 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 } from '../../../api';
@@ -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 AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown'; import AddHostDropdown from './AddHostDropdown';
@@ -195,6 +203,55 @@ function InventoryGroupHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<AdHocCommandsButton
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={hostCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
) : (
<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"
>
<AdHocCommandsButton
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={hostCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}

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: () => ({
@@ -95,6 +96,52 @@ describe('<InventoryGroupHostList />', () => {
}); });
}); });
test('should render enabled ad hoc commands button', async () => {
GroupsAPI.readAllHosts.mockResolvedValue({
data: { ...mockHosts },
});
InventoriesAPI.readHostsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
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 () => {
wrapper = mountWithContexts(
<InventoryGroupHostList>
{({ openAdHocCommands, isDisabled }) => (
<button
type="button"
variant="secondary"
className="run-command"
onClick={openAdHocCommands}
disabled={isDisabled}
/>
)}
</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

@@ -158,11 +158,11 @@ function InventoryGroupsList({ i18n }) {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)} itemId={parseInt(inventoryId, 10)}
> >
{({ openAdHocCommands }) => ( {({ openAdHocCommands, isDisabled }) => (
<DropdownItem <DropdownItem
key="run command" key="run command"
onClick={openAdHocCommands} onClick={openAdHocCommands}
isDisabled={groupCount === 0} isDisabled={groupCount === 0 || isDisabled}
> >
{i18n._(t`Run command`)} {i18n._(t`Run command`)}
</DropdownItem> </DropdownItem>
@@ -270,12 +270,12 @@ function InventoryGroupsList({ i18n }) {
apiModule={InventoriesAPI} apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)} itemId={parseInt(inventoryId, 10)}
> >
{({ openAdHocCommands }) => ( {({ openAdHocCommands, isDisabled }) => (
<Button <Button
variant="secondary" variant="secondary"
aria-label={i18n._(t`Run command`)} aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands} onClick={openAdHocCommands}
isDisabled={groupCount === 0} isDisabled={groupCount === 0 || isDisabled}
> >
{i18n._(t`Run command`)} {i18n._(t`Run command`)}
</Button> </Button>

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,13 +71,34 @@ 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'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route path="/inventories/inventory/:id/groups"> <Route path="/inventories/inventory/:id/groups">
<InventoryGroupsList /> <InventoryGroupsList>
{({ openAdHocCommands, isDisabled }) => (
<button
type="button"
variant="secondary"
className="run-command"
onClick={openAdHocCommands}
disabled={isDisabled}
/>
)}
</InventoryGroupsList>
</Route>, </Route>,
{ {
context: { context: {
@@ -147,31 +168,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 +204,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 +291,37 @@ 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>
{({ openAdHocCommands, isDisabled }) => (
<button
type="button"
variant="secondary"
className="run-command"
onClick={openAdHocCommands}
disabled={isDisabled}
/>
)}
</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,6 +2,12 @@ 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,
@@ -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 AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostGroupItem from './InventoryHostGroupItem'; import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -201,6 +209,55 @@ function InventoryHostGroupsList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<AdHocCommandsButton
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(invId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={itemCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
) : (
<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"
>
<AdHocCommandsButton
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(invId, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={itemCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton <DisassociateButton
key="disassociate" key="disassociate"
onDisassociate={handleDisassociate} onDisassociate={handleDisassociate}
@@ -208,8 +265,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.
`)} `)}
/>, />,

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

@@ -2,6 +2,12 @@ import React, { useEffect, useState } 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 } from '../../../api';
@@ -12,6 +18,8 @@ import PaginatedDataList, {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem'; import InventoryHostItem from './InventoryHostItem';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
@@ -149,6 +157,55 @@ function InventoryHostList({ i18n }) {
/>, />,
] ]
: []), : []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<AdHocCommandsButton
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(id, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={hostCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
) : (
<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"
>
<AdHocCommandsButton
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(id, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={hostCount === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
onDelete={handleDelete} onDelete={handleDelete}

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,
@@ -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 />);
}); });
@@ -293,4 +304,11 @@ describe('<InventoryHostList />', () => {
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 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

@@ -2,7 +2,12 @@ import React, { useEffect, useCallback } 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';
@@ -11,6 +16,8 @@ import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,
@@ -89,12 +96,55 @@ function SmartInventoryHostList({ i18n, inventory }) {
additionalControls={ additionalControls={
inventory?.summary_fields?.user_capabilities?.adhoc inventory?.summary_fields?.user_capabilities?.adhoc
? [ ? [
<Button <Kebabified>
aria-label={i18n._(t`Run commands`)} {({ isKebabified }) =>
isDisabled={selected.length === 0} isKebabified ? (
> <AdHocCommandsButton
{i18n._(t`Run commands`)} adHocItems={selected}
</Button>, apiModule={InventoriesAPI}
itemId={parseInt(inventory.id, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={count === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
) : (
<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"
>
<AdHocCommandsButton
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventory.id, 10)}
>
{({ openAdHocCommands, isDisabled }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={count === 0 || isDisabled}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
] ]
: [] : []
} }

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,107 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api'); jest.mock('../../../api');
describe('<SmartInventoryHostList />', () => { describe('<SmartInventoryHostList />', () => {
describe('User has adhoc permissions', () => { // 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}>
}); {({ openAdHocCommands, isDisabled }) => (
<button
test('should fetch hosts from api and render them in the list', () => { type="button"
expect(InventoriesAPI.readHosts).toHaveBeenCalled(); variant="secondary"
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); className="run-command"
}); onClick={openAdHocCommands}
disabled={isDisabled}
test('should disable run commands button when no hosts are selected', () => { />
wrapper.find('DataListCheck').forEach(el => { )}
expect(el.props().checked).toBe(false); </SmartInventoryHostList>
});
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);
});
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 render enabled ad hoc commands button', async () => {
let wrapper; await waitForElement(
const clonedInventory = { wrapper,
...mockInventory, 'button[aria-label="Run command"]',
summary_fields: { el => el.prop('disabled') === false
user_capabilities: { );
adhoc: false, });
},
},
};
test('should hide run commands button', async () => { test('should show content error when api throws an error', async () => {
InventoriesAPI.readHosts.mockResolvedValue({ InventoriesAPI.readHosts.mockImplementation(() =>
data: { results: [], count: 0 }, Promise.reject(new Error())
}); );
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} /> <SmartInventoryHostList inventory={mockInventory} />
);
});
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 waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });