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}

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