Merge pull request #6559 from jlmitch5/newNewAssocDisassocHostGroupsList

association and disassociation of host groups and inventory host groups list.

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-04-06 23:07:19 +00:00
committed by GitHub
25 changed files with 982 additions and 135 deletions

View File

@@ -6,21 +6,34 @@ class Hosts extends Base {
this.baseUrl = '/api/v2/hosts/'; this.baseUrl = '/api/v2/hosts/';
this.readFacts = this.readFacts.bind(this); this.readFacts = this.readFacts.bind(this);
this.readGroups = this.readGroups.bind(this); this.readAllGroups = this.readAllGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this);
this.associateGroup = this.associateGroup.bind(this);
this.disassociateGroup = this.disassociateGroup.bind(this);
} }
readFacts(id) { readFacts(id) {
return this.http.get(`${this.baseUrl}${id}/ansible_facts/`); return this.http.get(`${this.baseUrl}${id}/ansible_facts/`);
} }
readGroups(id, params) { readAllGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/groups/`, { params }); return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params });
} }
readGroupsOptions(id) { readGroupsOptions(id) {
return this.http.options(`${this.baseUrl}${id}/groups/`); return this.http.options(`${this.baseUrl}${id}/groups/`);
} }
associateGroup(id, groupId) {
return this.http.post(`${this.baseUrl}${id}/groups/`, { id: groupId });
}
disassociateGroup(id, group) {
return this.http.post(`${this.baseUrl}${id}/groups/`, {
id: group.id,
disassociate: true,
});
}
} }
export default Hosts; export default Hosts;

View File

@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import AssociateModal from './AssociateModal'; import AssociateModal from './AssociateModal';
import mockHosts from '../shared/data.hosts.json'; import mockHosts from './data.hosts.json';
jest.mock('@api'); jest.mock('@api');

View File

@@ -0,0 +1,393 @@
{
"count": 3,
"results": [
{
"id": 2,
"type": "host",
"url": "/api/v2/hosts/2/",
"related": {
"created_by": "/api/v2/users/10/",
"modified_by": "/api/v2/users/19/",
"variable_data": "/api/v2/hosts/2/variable_data/",
"groups": "/api/v2/hosts/2/groups/",
"all_groups": "/api/v2/hosts/2/all_groups/",
"job_events": "/api/v2/hosts/2/job_events/",
"job_host_summaries": "/api/v2/hosts/2/job_host_summaries/",
"activity_stream": "/api/v2/hosts/2/activity_stream/",
"inventory_sources": "/api/v2/hosts/2/inventory_sources/",
"smart_inventories": "/api/v2/hosts/2/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/",
"insights": "/api/v2/hosts/2/insights/",
"ansible_facts": "/api/v2/hosts/2/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2202/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2202,
"failed": false
},
"created_by": {
"id": 10,
"username": "user-3",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 19,
"username": "all",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.922179Z",
"modified": "2020-02-26T21:52:43.428530Z",
"name": ".host-000001.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2202,
"insights_system_id": null,
"ansible_facts_modified": null
},
{
"id": 3,
"type": "host",
"url": "/api/v2/hosts/3/",
"related": {
"created_by": "/api/v2/users/11/",
"modified_by": "/api/v2/users/1/",
"variable_data": "/api/v2/hosts/3/variable_data/",
"groups": "/api/v2/hosts/3/groups/",
"all_groups": "/api/v2/hosts/3/all_groups/",
"job_events": "/api/v2/hosts/3/job_events/",
"job_host_summaries": "/api/v2/hosts/3/job_host_summaries/",
"activity_stream": "/api/v2/hosts/3/activity_stream/",
"inventory_sources": "/api/v2/hosts/3/inventory_sources/",
"smart_inventories": "/api/v2/hosts/3/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/3/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/3/ad_hoc_command_events/",
"insights": "/api/v2/hosts/3/insights/",
"ansible_facts": "/api/v2/hosts/3/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2195/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2195,
"failed": false
},
"created_by": {
"id": 11,
"username": "user-4",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.945113Z",
"modified": "2020-02-27T03:43:43.635871Z",
"name": ".host-000002.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2195,
"insights_system_id": null,
"ansible_facts_modified": null
},
{
"id": 4,
"type": "host",
"url": "/api/v2/hosts/4/",
"related": {
"created_by": "/api/v2/users/12/",
"modified_by": "/api/v2/users/1/",
"variable_data": "/api/v2/hosts/4/variable_data/",
"groups": "/api/v2/hosts/4/groups/",
"all_groups": "/api/v2/hosts/4/all_groups/",
"job_events": "/api/v2/hosts/4/job_events/",
"job_host_summaries": "/api/v2/hosts/4/job_host_summaries/",
"activity_stream": "/api/v2/hosts/4/activity_stream/",
"inventory_sources": "/api/v2/hosts/4/inventory_sources/",
"smart_inventories": "/api/v2/hosts/4/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/4/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/4/ad_hoc_command_events/",
"insights": "/api/v2/hosts/4/insights/",
"ansible_facts": "/api/v2/hosts/4/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2192/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2192,
"failed": false
},
"created_by": {
"id": 12,
"username": "user-5",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.962312Z",
"modified": "2020-02-27T03:43:45.528882Z",
"name": ".host-000003.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2192,
"insights_system_id": null,
"ansible_facts_modified": null
}
]
}

View File

@@ -0,0 +1 @@
export { default } from './AssociateModal';

View File

@@ -0,0 +1 @@
export { default } from './DisassociateButton';

View File

@@ -1,18 +1,18 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter } from 'react-router-dom'; import { Switch, Route } from 'react-router-dom';
import HostGroupsList from './HostGroupsList'; import HostGroupsList from './HostGroupsList';
function HostGroups({ location, match }) { function HostGroups({ host }) {
return ( return (
<Switch> <Switch>
<Route <Route
key="list" key="list"
path="/hosts/:id/groups" path="/hosts/:id/groups"
render={() => { render={() => {
return <HostGroupsList location={location} match={match} />; return <HostGroupsList host={host} />;
}} }}
/> />
</Switch> </Switch>
@@ -20,4 +20,4 @@ function HostGroups({ location, match }) {
} }
export { HostGroups as _HostGroups }; export { HostGroups as _HostGroups };
export default withI18n()(withRouter(HostGroups)); export default withI18n()(HostGroups);

View File

@@ -12,7 +12,11 @@ describe('<HostGroups />', () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/hosts/1/groups'], initialEntries: ['/hosts/1/groups'],
}); });
const host = { id: 1, name: 'Foo' }; const host = {
id: 1,
name: 'Foo',
summary_fields: { inventory: { id: 1 } },
};
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(

View File

@@ -1,12 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } 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 { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import useRequest from '@util/useRequest'; import useRequest, {
import { HostsAPI } from '@api'; useDismissableError,
useDeleteItems,
} from '@util/useRequest';
import useSelected from '@util/useSelected';
import { HostsAPI, InventoriesAPI } from '@api';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import AssociateModal from '@components/AssociateModal';
import DisassociateButton from '@components/DisassociateButton';
import DataListToolbar from '@components/DataListToolbar'; import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList from '@components/PaginatedDataList';
import HostGroupItem from './HostGroupItem'; import HostGroupItem from './HostGroupItem';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -15,29 +25,38 @@ const QS_CONFIG = getQSConfig('group', {
order_by: 'name', order_by: 'name',
}); });
function HostGroupsList({ i18n, location, match }) { function HostGroupsList({ i18n, host }) {
const [selected, setSelected] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false);
const hostId = match.params.id; const { id: hostId } = useParams();
const { search } = useLocation();
const invId = host.summary_fields.inventory.id;
const { const {
result: { groups, itemCount }, result: { groups, itemCount, actions },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchGroups, request: fetchGroups,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, search);
const { const [
data: { count, results }, {
} = await HostsAPI.readGroups(hostId, params); data: { count, results },
},
actionsResponse,
] = await Promise.all([
HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId),
]);
return { return {
itemCount: count,
groups: results, groups: results,
itemCount: count,
actions: actionsResponse.data.actions,
}; };
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps }, [hostId, search]),
{ {
groups: [], groups: [],
itemCount: 0, itemCount: 0,
@@ -48,26 +67,68 @@ function HostGroupsList({ i18n, location, match }) {
fetchGroups(); fetchGroups();
}, [fetchGroups]); }, [fetchGroups]);
const handleSelectAll = isSelected => { const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
setSelected(isSelected ? [...groups] : []); groups
}; );
const handleSelect = row => { const {
if (selected.some(s => s.id === row.id)) { isLoading: isDisassociateLoading,
setSelected(selected.filter(s => s.id !== row.id)); deleteItems: disassociateHosts,
} else { deletionError: disassociateError,
setSelected(selected.concat(row)); } = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(group => HostsAPI.disassociateGroup(hostId, group))
);
}, [hostId, selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchGroups,
} }
);
const handleDisassociate = async () => {
await disassociateHosts();
setSelected([]);
}; };
const isAllSelected = const fetchGroupsToAssociate = useCallback(
selected.length > 0 && selected.length === groups.length; params => {
return InventoriesAPI.readGroups(
invId,
mergeParams(params, { not__hosts: hostId })
);
},
[invId, hostId]
);
const { request: handleAssociate, error: associateError } = useRequest(
useCallback(
async groupsToAssociate => {
await Promise.all(
groupsToAssociate.map(group =>
HostsAPI.associateGroup(hostId, group.id)
)
);
fetchGroups();
},
[hostId, fetchGroups]
)
);
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
return ( return (
<> <>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading} hasContentLoading={isLoading || isDisassociateLoading}
items={groups} items={groups}
itemCount={itemCount} itemCount={itemCount}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
@@ -108,12 +169,64 @@ function HostGroupsList({ i18n, location, match }) {
{...props} {...props}
showSelectAll showSelectAll
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={handleSelectAll} onSelectAll={isSelected =>
setSelected(isSelected ? [...groups] : [])
}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="add"
onClick={() => setIsModalOpen(true)}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(t`Disassociate group from host?`)}
modalNote={i18n._(t`
Note that you may still see the group in the list after
disassociating if the host is also a member of that groups
children. This list shows all groups the host is associated
with directly and indirectly.
`)}
/>,
]}
/> />
)} )}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" onClick={() => setIsModalOpen(true)} />
) : null
}
/> />
{isModalOpen && (
<AssociateModal
header={i18n._(t`Groups`)}
fetchRequest={fetchGroupsToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Groups`)}
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more groups.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</> </>
); );
} }
export default withI18n()(withRouter(HostGroupsList)); export default withI18n()(HostGroupsList);

View File

@@ -3,11 +3,19 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { HostsAPI } from '@api'; import { HostsAPI, InventoriesAPI } from '@api';
import HostGroupsList from './HostGroupsList'; import HostGroupsList from './HostGroupsList';
jest.mock('@api'); jest.mock('@api');
const host = {
summary_fields: {
inventory: {
id: 1,
},
},
};
const mockGroups = [ const mockGroups = [
{ {
id: 1, id: 1,
@@ -52,7 +60,7 @@ const mockGroups = [
id: 1, id: 1,
}, },
user_capabilities: { user_capabilities: {
delete: false, delete: true,
edit: false, edit: false,
}, },
}, },
@@ -63,7 +71,7 @@ describe('<HostGroupsList />', () => {
let wrapper; let wrapper;
beforeEach(async () => { beforeEach(async () => {
HostsAPI.readGroups.mockResolvedValue({ HostsAPI.readAllGroups.mockResolvedValue({
data: { data: {
count: mockGroups.length, count: mockGroups.length,
results: mockGroups, results: mockGroups,
@@ -82,7 +90,9 @@ describe('<HostGroupsList />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route path="/hosts/:id/groups" component={() => <HostGroupsList />} />, <Route path="/hosts/:id/groups">
<HostGroupsList host={host} />
</Route>,
{ {
context: { context: {
router: { history, route: { location: history.location } }, router: { history, route: { location: history.location } },
@@ -93,12 +103,17 @@ describe('<HostGroupsList />', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => { test('initially renders successfully', () => {
expect(wrapper.find('HostGroupsList').length).toBe(1); expect(wrapper.find('HostGroupsList').length).toBe(1);
}); });
test('should fetch groups from api and render them in the list', async () => { test('should fetch groups from api and render them in the list', async () => {
expect(HostsAPI.readGroups).toHaveBeenCalled(); expect(HostsAPI.readAllGroups).toHaveBeenCalled();
expect(wrapper.find('HostGroupItem').length).toBe(3); expect(wrapper.find('HostGroupItem').length).toBe(3);
}); });
@@ -149,10 +164,108 @@ describe('<HostGroupsList />', () => {
}); });
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 () => {
HostsAPI.readGroups.mockImplementation(() => Promise.reject(new Error())); HostsAPI.readAllGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<HostGroupsList />); wrapper = mountWithContexts(<HostGroupsList host={host} />);
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
test('should show add button according to permissions', async () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
HostsAPI.readGroupsOptions.mockResolvedValueOnce({
data: {
actions: {
GET: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<HostGroupsList host={host} />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
test('should show associate group modal when adding an existing group', () => {
wrapper.find('ToolbarAddButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(0);
});
test('should make expected api request when associating groups', async () => {
HostsAPI.associateGroup.mockResolvedValue();
InventoriesAPI.readGroups.mockResolvedValue({
data: {
count: 1,
results: [{ id: 123, name: 'foo', url: '/api/v2/groups/123/' }],
},
});
await act(async () => {
wrapper.find('ToolbarAddButton').simulate('click');
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
wrapper.update();
await act(async () => {
wrapper
.find('CheckboxListItem')
.first()
.invoke('onSelect')();
});
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
await waitForElement(wrapper, 'AssociateModal', el => el.length === 0);
expect(InventoriesAPI.readGroups).toHaveBeenCalledTimes(1);
expect(HostsAPI.associateGroup).toHaveBeenCalledTimes(1);
});
test('expected api calls are made for multi-disassociation', async () => {
expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0);
expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(1);
expect(wrapper.find('DataListCheck').length).toBe(3);
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
expect(wrapper.find('AlertModal Title').text()).toEqual(
'Disassociate group from host?'
);
await act(async () => {
wrapper
.find('button[aria-label="confirm disassociate"]')
.simulate('click');
});
expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3);
expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(2);
});
test('should show error modal for failed disassociation', async () => {
HostsAPI.disassociateGroup.mockRejectedValue(new Error());
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
expect(wrapper.find('AlertModal Title').text()).toEqual(
'Disassociate group from host?'
);
await act(async () => {
wrapper
.find('button[aria-label="confirm disassociate"]')
.simulate('click');
});
wrapper.update();
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
});
}); });

View File

@@ -60,6 +60,8 @@ class Inventories extends Component {
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
t`Completed Jobs` t`Completed Jobs`
), ),
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`), [`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`),

View File

@@ -43,12 +43,9 @@ describe('<InventoryGroup />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/groups">
path="/inventories/inventory/:id/groups" <InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
component={() => ( </Route>,
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
)}
/>,
{ context: { router: { history } } } { context: { router: { history } } }
); );
}); });

View File

@@ -19,10 +19,9 @@ describe('<InventoryGroupAdd />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/groups/add">
path="/inventories/inventory/:id/groups/add" <InventoryGroupAdd />
component={() => <InventoryGroupAdd />} </Route>,
/>,
{ {
context: { context: {
router: { history, route: { location: history.location } }, router: { history, route: { location: history.location } },

View File

@@ -36,12 +36,9 @@ describe('<InventoryGroupDetail />', () => {
initialEntries: ['/inventories/inventory/1/groups/1/details'], initialEntries: ['/inventories/inventory/1/groups/1/details'],
}); });
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/groups/:groupId">
path="/inventories/inventory/:id/groups/:groupId" <InventoryGroupDetail inventoryGroup={inventoryGroup} />
component={() => ( </Route>,
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
)}
/>,
{ {
context: { context: {
router: { router: {

View File

@@ -24,10 +24,9 @@ describe('<InventoryGroupEdit />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/groups/:groupId/edit">
path="/inventories/inventory/:id/groups/:groupId/edit" <InventoryGroupEdit inventoryGroup={{ id: 2 }} />
component={() => <InventoryGroupEdit inventoryGroup={{ id: 2 }} />} </Route>,
/>,
{ {
context: { context: {
router: { router: {

View File

@@ -14,10 +14,10 @@ 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';
import PaginatedDataList from '@components/PaginatedDataList'; import PaginatedDataList from '@components/PaginatedDataList';
import AssociateModal from '@components/AssociateModal';
import DisassociateButton from '@components/DisassociateButton';
import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AssociateModal from './AssociateModal';
import AddHostDropdown from './AddHostDropdown'; import AddHostDropdown from './AddHostDropdown';
import DisassociateButton from './DisassociateButton';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,

View File

@@ -1,14 +1,14 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter } from 'react-router-dom'; import { Switch, Route } from 'react-router-dom';
import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd'; import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd';
import InventoryGroup from '../InventoryGroup/InventoryGroup'; import InventoryGroup from '../InventoryGroup/InventoryGroup';
import InventoryGroupsList from './InventoryGroupsList'; import InventoryGroupsList from './InventoryGroupsList';
function InventoryGroups({ setBreadcrumb, inventory, location, match }) { function InventoryGroups({ setBreadcrumb, inventory }) {
return ( return (
<Switch> <Switch>
<Route <Route
@@ -34,7 +34,7 @@ function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
key="list" key="list"
path="/inventories/inventory/:id/groups" path="/inventories/inventory/:id/groups"
render={() => { render={() => {
return <InventoryGroupsList location={location} match={match} />; return <InventoryGroupsList />;
}} }}
/> />
</Switch> </Switch>
@@ -42,4 +42,4 @@ function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
} }
export { InventoryGroups as _InventoryGroups }; export { InventoryGroups as _InventoryGroups };
export default withI18n()(withRouter(InventoryGroups)); export default withI18n()(InventoryGroups);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { withRouter } 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 { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
@@ -37,7 +37,7 @@ const useModal = () => {
}; };
}; };
function InventoryGroupsList({ i18n, location, match }) { function InventoryGroupsList({ i18n }) {
const [actions, setActions] = useState(null); const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null); const [deletionError, setDeletionError] = useState(null);
@@ -47,7 +47,8 @@ function InventoryGroupsList({ i18n, location, match }) {
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal(); const { isModalOpen, toggleModal } = useModal();
const inventoryId = match.params.id; const { id: inventoryId } = useParams();
const { search } = useLocation();
const fetchGroups = (id, queryString) => { const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString); const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params); return InventoriesAPI.readGroups(id, params);
@@ -64,7 +65,7 @@ function InventoryGroupsList({ i18n, location, match }) {
data: { actions: optionActions }, data: { actions: optionActions },
}, },
] = await Promise.all([ ] = await Promise.all([
fetchGroups(inventoryId, location.search), fetchGroups(inventoryId, search),
InventoriesAPI.readGroupsOptions(inventoryId), InventoriesAPI.readGroupsOptions(inventoryId),
]); ]);
@@ -78,7 +79,7 @@ function InventoryGroupsList({ i18n, location, match }) {
} }
} }
fetchData(); fetchData();
}, [inventoryId, location]); }, [inventoryId, search]);
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []); setSelected(isSelected ? [...groups] : []);
@@ -138,7 +139,7 @@ function InventoryGroupsList({ i18n, location, match }) {
try { try {
const { const {
data: { count, results }, data: { count, results },
} = await fetchGroups(inventoryId, location.search); } = await fetchGroups(inventoryId, search);
setGroups(results); setGroups(results);
setGroupCount(count); setGroupCount(count);
} catch (error) { } catch (error) {
@@ -263,4 +264,4 @@ function InventoryGroupsList({ i18n, location, match }) {
</> </>
); );
} }
export default withI18n()(withRouter(InventoryGroupsList)); export default withI18n()(InventoryGroupsList);

View File

@@ -73,10 +73,9 @@ describe('<InventoryGroupsList />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/groups">
path="/inventories/inventory/:id/groups" <InventoryGroupsList />
component={() => <InventoryGroupsList />} </Route>,
/>,
{ {
context: { context: {
router: { history, route: { location: history.location } }, router: { history, route: { location: history.location } },

View File

@@ -1,18 +1,18 @@
import React from 'react'; import React from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter } from 'react-router-dom'; import { Switch, Route } from 'react-router-dom';
import InventoryHostGroupsList from './InventoryHostGroupsList'; import InventoryHostGroupsList from './InventoryHostGroupsList';
function InventoryHostGroups({ location, match }) { function InventoryHostGroups() {
return ( return (
<Switch> <Switch>
<Route <Route
key="list" key="list"
path="/inventories/inventory/:id/hosts/:hostId/groups" path="/inventories/inventory/:id/hosts/:hostId/groups"
render={() => { render={() => {
return <InventoryHostGroupsList location={location} match={match} />; return <InventoryHostGroupsList />;
}} }}
/> />
</Switch> </Switch>
@@ -20,4 +20,4 @@ function InventoryHostGroups({ location, match }) {
} }
export { InventoryHostGroups as _InventoryHostGroups }; export { InventoryHostGroups as _InventoryHostGroups };
export default withI18n()(withRouter(InventoryHostGroups)); export default withI18n()(InventoryHostGroups);

View File

@@ -1,12 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } 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 { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import useRequest from '@util/useRequest'; import useRequest, {
import { HostsAPI } from '@api'; useDismissableError,
useDeleteItems,
} from '@util/useRequest';
import useSelected from '@util/useSelected';
import { HostsAPI, InventoriesAPI } from '@api';
import DataListToolbar from '@components/DataListToolbar'; import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList from '@components/PaginatedDataList'; import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import AssociateModal from '@components/AssociateModal';
import DisassociateButton from '@components/DisassociateButton';
import InventoryHostGroupItem from './InventoryHostGroupItem'; import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', { const QS_CONFIG = getQSConfig('group', {
@@ -15,29 +25,36 @@ const QS_CONFIG = getQSConfig('group', {
order_by: 'name', order_by: 'name',
}); });
function InventoryHostGroupsList({ i18n, location, match }) { function InventoryHostGroupsList({ i18n }) {
const [selected, setSelected] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false);
const { hostId, id: invId } = useParams();
const { hostId } = match.params; const { search } = useLocation();
const { const {
result: { groups, itemCount }, result: { groups, itemCount, actions },
error: contentError, error: contentError,
isLoading, isLoading,
request: fetchGroups, request: fetchGroups,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(QS_CONFIG, search);
const { const [
data: { count, results }, {
} = await HostsAPI.readGroups(hostId, params); data: { count, results },
},
actionsResponse,
] = await Promise.all([
HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId),
]);
return { return {
itemCount: count,
groups: results, groups: results,
itemCount: count,
actions: actionsResponse.data.actions,
}; };
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps
{ {
groups: [], groups: [],
itemCount: 0, itemCount: 0,
@@ -48,26 +65,68 @@ function InventoryHostGroupsList({ i18n, location, match }) {
fetchGroups(); fetchGroups();
}, [fetchGroups]); }, [fetchGroups]);
const handleSelectAll = isSelected => { const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
setSelected(isSelected ? [...groups] : []); groups
}; );
const handleSelect = row => { const {
if (selected.some(s => s.id === row.id)) { isLoading: isDisassociateLoading,
setSelected(selected.filter(s => s.id !== row.id)); deleteItems: disassociateHosts,
} else { deletionError: disassociateError,
setSelected(selected.concat(row)); } = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(group => HostsAPI.disassociateGroup(hostId, group))
);
}, [hostId, selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchGroups,
} }
);
const handleDisassociate = async () => {
await disassociateHosts();
setSelected([]);
}; };
const isAllSelected = const fetchGroupsToAssociate = useCallback(
selected.length > 0 && selected.length === groups.length; params => {
return InventoriesAPI.readGroups(
invId,
mergeParams(params, { not__hosts: hostId })
);
},
[invId, hostId]
);
const { request: handleAssociate, error: associateError } = useRequest(
useCallback(
async groupsToAssociate => {
await Promise.all(
groupsToAssociate.map(group =>
HostsAPI.associateGroup(hostId, group.id)
)
);
fetchGroups();
},
[hostId, fetchGroups]
)
);
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
return ( return (
<> <>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading} hasContentLoading={isLoading || isDisassociateLoading}
items={groups} items={groups}
itemCount={itemCount} itemCount={itemCount}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
@@ -107,12 +166,64 @@ function InventoryHostGroupsList({ i18n, location, match }) {
{...props} {...props}
showSelectAll showSelectAll
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={handleSelectAll} onSelectAll={isSelected =>
setSelected(isSelected ? [...groups] : [])
}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="add"
onClick={() => setIsModalOpen(true)}
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(t`Disassociate group from host?`)}
modalNote={i18n._(t`
Note that you may still see the group in the list after
disassociating if the host is also a member of that groups
children. This list shows all groups the host is associated
with directly and indirectly.
`)}
/>,
]}
/> />
)} )}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" onClick={() => setIsModalOpen(true)} />
) : null
}
/> />
{isModalOpen && (
<AssociateModal
header={i18n._(t`Groups`)}
fetchRequest={fetchGroupsToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Groups`)}
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more groups.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</> </>
); );
} }
export default withI18n()(withRouter(InventoryHostGroupsList)); export default withI18n()(InventoryHostGroupsList);

View File

@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { HostsAPI } from '@api'; import { HostsAPI, InventoriesAPI } from '@api';
import InventoryHostGroupsList from './InventoryHostGroupsList'; import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('@api'); jest.mock('@api');
@@ -52,7 +52,7 @@ const mockGroups = [
id: 1, id: 1,
}, },
user_capabilities: { user_capabilities: {
delete: false, delete: true,
edit: false, edit: false,
}, },
}, },
@@ -63,7 +63,7 @@ describe('<InventoryHostGroupsList />', () => {
let wrapper; let wrapper;
beforeEach(async () => { beforeEach(async () => {
HostsAPI.readGroups.mockResolvedValue({ HostsAPI.readAllGroups.mockResolvedValue({
data: { data: {
count: mockGroups.length, count: mockGroups.length,
results: mockGroups, results: mockGroups,
@@ -82,10 +82,9 @@ describe('<InventoryHostGroupsList />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route path="/inventories/inventory/:id/hosts/:hostId/groups">
path="/inventories/inventory/:id/hosts/:hostId/groups" <InventoryHostGroupsList />
component={() => <InventoryHostGroupsList />} </Route>,
/>,
{ {
context: { context: {
router: { history, route: { location: history.location } }, router: { history, route: { location: history.location } },
@@ -96,12 +95,17 @@ describe('<InventoryHostGroupsList />', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => { test('initially renders successfully', () => {
expect(wrapper.find('InventoryHostGroupsList').length).toBe(1); expect(wrapper.find('InventoryHostGroupsList').length).toBe(1);
}); });
test('should fetch groups from api and render them in the list', async () => { test('should fetch groups from api and render them in the list', async () => {
expect(HostsAPI.readGroups).toHaveBeenCalled(); expect(HostsAPI.readAllGroups).toHaveBeenCalled();
expect(wrapper.find('InventoryHostGroupItem').length).toBe(3); expect(wrapper.find('InventoryHostGroupItem').length).toBe(3);
}); });
@@ -152,10 +156,108 @@ describe('<InventoryHostGroupsList />', () => {
}); });
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 () => {
HostsAPI.readGroups.mockImplementation(() => Promise.reject(new Error())); HostsAPI.readAllGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHostGroupsList />); wrapper = mountWithContexts(<InventoryHostGroupsList />);
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
test('should show add button according to permissions', async () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
HostsAPI.readGroupsOptions.mockResolvedValueOnce({
data: {
actions: {
GET: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHostGroupsList />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
test('should show associate group modal when adding an existing group', () => {
wrapper.find('ToolbarAddButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(0);
});
test('should make expected api request when associating groups', async () => {
HostsAPI.associateGroup.mockResolvedValue();
InventoriesAPI.readGroups.mockResolvedValue({
data: {
count: 1,
results: [{ id: 123, name: 'foo', url: '/api/v2/groups/123/' }],
},
});
await act(async () => {
wrapper.find('ToolbarAddButton').simulate('click');
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
wrapper.update();
await act(async () => {
wrapper
.find('CheckboxListItem')
.first()
.invoke('onSelect')();
});
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
await waitForElement(wrapper, 'AssociateModal', el => el.length === 0);
expect(InventoriesAPI.readGroups).toHaveBeenCalledTimes(1);
expect(HostsAPI.associateGroup).toHaveBeenCalledTimes(1);
});
test('expected api calls are made for multi-disassociation', async () => {
expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0);
expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(1);
expect(wrapper.find('DataListCheck').length).toBe(3);
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
expect(wrapper.find('AlertModal Title').text()).toEqual(
'Disassociate group from host?'
);
await act(async () => {
wrapper
.find('button[aria-label="confirm disassociate"]')
.simulate('click');
});
expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3);
expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(2);
});
test('should show error modal for failed disassociation', async () => {
HostsAPI.disassociateGroup.mockRejectedValue(new Error());
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
expect(wrapper.find('AlertModal Title').text()).toEqual(
'Disassociate group from host?'
);
await act(async () => {
wrapper
.find('button[aria-label="confirm disassociate"]')
.simulate('click');
});
wrapper.update();
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
});
}); });

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { withRouter } 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 { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
@@ -20,7 +20,7 @@ const QS_CONFIG = getQSConfig('host', {
order_by: 'name', order_by: 'name',
}); });
function InventoryHostList({ i18n, location, match }) { function InventoryHostList({ i18n }) {
const [actions, setActions] = useState(null); const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null); const [deletionError, setDeletionError] = useState(null);
@@ -28,10 +28,12 @@ function InventoryHostList({ i18n, location, match }) {
const [hosts, setHosts] = useState([]); const [hosts, setHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const { id } = useParams();
const { search } = useLocation();
const fetchHosts = (id, queryString) => { const fetchHosts = (hostId, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString); const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readHosts(id, params); return InventoriesAPI.readHosts(hostId, params);
}; };
useEffect(() => { useEffect(() => {
@@ -45,7 +47,7 @@ function InventoryHostList({ i18n, location, match }) {
data: { actions: optionActions }, data: { actions: optionActions },
}, },
] = await Promise.all([ ] = await Promise.all([
fetchHosts(match.params.id, location.search), fetchHosts(id, search),
InventoriesAPI.readOptions(), InventoriesAPI.readOptions(),
]); ]);
@@ -60,7 +62,7 @@ function InventoryHostList({ i18n, location, match }) {
} }
fetchData(); fetchData();
}, [match.params.id, location]); }, [id, search]);
const handleSelectAll = isSelected => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []); setSelected(isSelected ? [...hosts] : []);
@@ -86,7 +88,7 @@ function InventoryHostList({ i18n, location, match }) {
try { try {
const { const {
data: { count, results }, data: { count, results },
} = await fetchHosts(match.params.id, location.search); } = await fetchHosts(id, search);
setHosts(results); setHosts(results);
setHostCount(count); setHostCount(count);
@@ -143,7 +145,7 @@ function InventoryHostList({ i18n, location, match }) {
? [ ? [
<ToolbarAddButton <ToolbarAddButton
key="add" key="add"
linkTo={`/inventories/inventory/${match.params.id}/hosts/add`} linkTo={`/inventories/inventory/${id}/hosts/add`}
/>, />,
] ]
: []), : []),
@@ -160,8 +162,8 @@ function InventoryHostList({ i18n, location, match }) {
<InventoryHostItem <InventoryHostItem
key={o.id} key={o.id}
host={o} host={o}
detailUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/details`} detailUrl={`/inventories/inventory/${id}/hosts/${o.id}/details`}
editUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/edit`} editUrl={`/inventories/inventory/${id}/hosts/${o.id}/edit`}
isSelected={selected.some(row => row.id === o.id)} isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)} onSelect={() => handleSelect(o)}
/> />
@@ -170,7 +172,7 @@ function InventoryHostList({ i18n, location, match }) {
canAdd && ( canAdd && (
<ToolbarAddButton <ToolbarAddButton
key="add" key="add"
linkTo={`/inventories/inventory/${match.params.id}/add`} linkTo={`/inventories/inventory/${id}/add`}
/> />
) )
} }
@@ -190,4 +192,4 @@ function InventoryHostList({ i18n, location, match }) {
); );
} }
export default withI18n()(withRouter(InventoryHostList)); export default withI18n()(InventoryHostList);