diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js
index 2d13b00072..ae90bf2826 100644
--- a/awx/ui_next/src/api/models/Hosts.js
+++ b/awx/ui_next/src/api/models/Hosts.js
@@ -6,21 +6,34 @@ class Hosts extends Base {
this.baseUrl = '/api/v2/hosts/';
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.associateGroup = this.associateGroup.bind(this);
+ this.disassociateGroup = this.disassociateGroup.bind(this);
}
readFacts(id) {
return this.http.get(`${this.baseUrl}${id}/ansible_facts/`);
}
- readGroups(id, params) {
- return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
+ readAllGroups(id, params) {
+ return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params });
}
readGroupsOptions(id) {
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;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx
similarity index 100%
rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx
rename to awx/ui_next/src/components/AssociateModal/AssociateModal.jsx
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.test.jsx
similarity index 97%
rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx
rename to awx/ui_next/src/components/AssociateModal/AssociateModal.test.jsx
index 7f3eca721d..f6c4fca934 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx
+++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.test.jsx
@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import AssociateModal from './AssociateModal';
-import mockHosts from '../shared/data.hosts.json';
+import mockHosts from './data.hosts.json';
jest.mock('@api');
diff --git a/awx/ui_next/src/components/AssociateModal/data.hosts.json b/awx/ui_next/src/components/AssociateModal/data.hosts.json
new file mode 100644
index 0000000000..07c6ef7d9f
--- /dev/null
+++ b/awx/ui_next/src/components/AssociateModal/data.hosts.json
@@ -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
+ }
+ ]
+}
diff --git a/awx/ui_next/src/components/AssociateModal/index.js b/awx/ui_next/src/components/AssociateModal/index.js
new file mode 100644
index 0000000000..1a9df3aa33
--- /dev/null
+++ b/awx/ui_next/src/components/AssociateModal/index.js
@@ -0,0 +1 @@
+export { default } from './AssociateModal';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx b/awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx
similarity index 100%
rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx
rename to awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx b/awx/ui_next/src/components/DisassociateButton/DisassociateButton.test.jsx
similarity index 100%
rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx
rename to awx/ui_next/src/components/DisassociateButton/DisassociateButton.test.jsx
diff --git a/awx/ui_next/src/components/DisassociateButton/index.js b/awx/ui_next/src/components/DisassociateButton/index.js
new file mode 100644
index 0000000000..c64669bc23
--- /dev/null
+++ b/awx/ui_next/src/components/DisassociateButton/index.js
@@ -0,0 +1 @@
+export { default } from './DisassociateButton';
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
index 70a51dc51b..ec4fe08ceb 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx
@@ -1,18 +1,18 @@
import React from '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';
-function HostGroups({ location, match }) {
+function HostGroups({ host }) {
return (
{
- return ;
+ return ;
}}
/>
@@ -20,4 +20,4 @@ function HostGroups({ location, match }) {
}
export { HostGroups as _HostGroups };
-export default withI18n()(withRouter(HostGroups));
+export default withI18n()(HostGroups);
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
index 46aadcf637..bc13293622 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx
@@ -12,7 +12,11 @@ describe('', () => {
const history = createMemoryHistory({
initialEntries: ['/hosts/1/groups'],
});
- const host = { id: 1, name: 'Foo' };
+ const host = {
+ id: 1,
+ name: 'Foo',
+ summary_fields: { inventory: { id: 1 } },
+ };
await act(async () => {
wrapper = mountWithContexts(
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
index 371329aa97..aa813b24a3 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx
@@ -1,12 +1,22 @@
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 { t } from '@lingui/macro';
-import { getQSConfig, parseQueryString } from '@util/qs';
-import useRequest from '@util/useRequest';
-import { HostsAPI } from '@api';
+import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
+import useRequest, {
+ 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 PaginatedDataList from '@components/PaginatedDataList';
import HostGroupItem from './HostGroupItem';
const QS_CONFIG = getQSConfig('group', {
@@ -15,29 +25,38 @@ const QS_CONFIG = getQSConfig('group', {
order_by: 'name',
});
-function HostGroupsList({ i18n, location, match }) {
- const [selected, setSelected] = useState([]);
+function HostGroupsList({ i18n, host }) {
+ 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 {
- result: { groups, itemCount },
+ result: { groups, itemCount, actions },
error: contentError,
isLoading,
request: fetchGroups,
} = useRequest(
useCallback(async () => {
- const params = parseQueryString(QS_CONFIG, location.search);
+ const params = parseQueryString(QS_CONFIG, search);
- const {
- data: { count, results },
- } = await HostsAPI.readGroups(hostId, params);
+ const [
+ {
+ data: { count, results },
+ },
+ actionsResponse,
+ ] = await Promise.all([
+ HostsAPI.readAllGroups(hostId, params),
+ HostsAPI.readGroupsOptions(hostId),
+ ]);
return {
- itemCount: count,
groups: results,
+ itemCount: count,
+ actions: actionsResponse.data.actions,
};
- }, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps
+ }, [hostId, search]),
{
groups: [],
itemCount: 0,
@@ -48,26 +67,68 @@ function HostGroupsList({ i18n, location, match }) {
fetchGroups();
}, [fetchGroups]);
- const handleSelectAll = isSelected => {
- setSelected(isSelected ? [...groups] : []);
- };
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ groups
+ );
- const handleSelect = row => {
- if (selected.some(s => s.id === row.id)) {
- setSelected(selected.filter(s => s.id !== row.id));
- } else {
- setSelected(selected.concat(row));
+ const {
+ isLoading: isDisassociateLoading,
+ deleteItems: disassociateHosts,
+ deletionError: disassociateError,
+ } = 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 =
- selected.length > 0 && selected.length === groups.length;
+ const fetchGroupsToAssociate = useCallback(
+ 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 (
<>
+ setSelected(isSelected ? [...groups] : [])
+ }
qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? [
+ setIsModalOpen(true)}
+ />,
+ ]
+ : []),
+ ,
+ ]}
/>
)}
+ emptyStateControls={
+ canAdd ? (
+ setIsModalOpen(true)} />
+ ) : null
+ }
/>
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ title={i18n._(t`Select Groups`)}
+ />
+ )}
+ {error && (
+
+ {associateError
+ ? i18n._(t`Failed to associate.`)
+ : i18n._(t`Failed to disassociate one or more groups.`)}
+
+
+ )}
>
);
}
-export default withI18n()(withRouter(HostGroupsList));
+export default withI18n()(HostGroupsList);
diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
index d472534e20..82f3bf61ab 100644
--- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx
@@ -3,11 +3,19 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import { HostsAPI } from '@api';
+import { HostsAPI, InventoriesAPI } from '@api';
import HostGroupsList from './HostGroupsList';
jest.mock('@api');
+const host = {
+ summary_fields: {
+ inventory: {
+ id: 1,
+ },
+ },
+};
+
const mockGroups = [
{
id: 1,
@@ -52,7 +60,7 @@ const mockGroups = [
id: 1,
},
user_capabilities: {
- delete: false,
+ delete: true,
edit: false,
},
},
@@ -63,7 +71,7 @@ describe('', () => {
let wrapper;
beforeEach(async () => {
- HostsAPI.readGroups.mockResolvedValue({
+ HostsAPI.readAllGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
@@ -82,7 +90,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- } />,
+
+
+ ,
{
context: {
router: { history, route: { location: history.location } },
@@ -93,12 +103,17 @@ describe('', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
test('initially renders successfully', () => {
expect(wrapper.find('HostGroupsList').length).toBe(1);
});
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);
});
@@ -149,10 +164,108 @@ describe('', () => {
});
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 () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts();
});
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();
+ });
+ 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);
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index 9becfc14c9..71ace802cc 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -60,6 +60,8 @@ class Inventories extends Component {
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
t`Completed Jobs`
),
+ [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
+ [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`),
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
index dc1dd9d57f..af9182d936 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
@@ -43,12 +43,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- (
- {}} inventory={inventory} />
- )}
- />,
+
+ {}} inventory={inventory} />
+ ,
{ context: { router: { history } } }
);
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx
index ccbbdb8bfb..f5ad16dabf 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx
@@ -19,10 +19,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- }
- />,
+
+
+ ,
{
context: {
router: { history, route: { location: history.location } },
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx
index cf1d2cca45..427a30522f 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx
@@ -36,12 +36,9 @@ describe('', () => {
initialEntries: ['/inventories/inventory/1/groups/1/details'],
});
wrapper = mountWithContexts(
- (
-
- )}
- />,
+
+
+ ,
{
context: {
router: {
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx
index fe9c678a23..4e0056d095 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx
@@ -24,10 +24,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- }
- />,
+
+
+ ,
{
context: {
router: {
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
index 6410769ff8..45107964f1 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
@@ -14,10 +14,10 @@ import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList from '@components/PaginatedDataList';
+import AssociateModal from '@components/AssociateModal';
+import DisassociateButton from '@components/DisassociateButton';
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
-import AssociateModal from './AssociateModal';
import AddHostDropdown from './AddHostDropdown';
-import DisassociateButton from './DisassociateButton';
const QS_CONFIG = getQSConfig('host', {
page: 1,
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
index 2917f3f96d..5b8ecdbbbb 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
@@ -1,14 +1,14 @@
import React from '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 InventoryGroup from '../InventoryGroup/InventoryGroup';
import InventoryGroupsList from './InventoryGroupsList';
-function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
+function InventoryGroups({ setBreadcrumb, inventory }) {
return (
{
- return ;
+ return ;
}}
/>
@@ -42,4 +42,4 @@ function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
}
export { InventoryGroups as _InventoryGroups };
-export default withI18n()(withRouter(InventoryGroups));
+export default withI18n()(InventoryGroups);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx
index f18f6ade7b..92576a9573 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx
@@ -1,5 +1,5 @@
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 { t } from '@lingui/macro';
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 [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
@@ -47,7 +47,8 @@ function InventoryGroupsList({ i18n, location, match }) {
const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal();
- const inventoryId = match.params.id;
+ const { id: inventoryId } = useParams();
+ const { search } = useLocation();
const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params);
@@ -64,7 +65,7 @@ function InventoryGroupsList({ i18n, location, match }) {
data: { actions: optionActions },
},
] = await Promise.all([
- fetchGroups(inventoryId, location.search),
+ fetchGroups(inventoryId, search),
InventoriesAPI.readGroupsOptions(inventoryId),
]);
@@ -78,7 +79,7 @@ function InventoryGroupsList({ i18n, location, match }) {
}
}
fetchData();
- }, [inventoryId, location]);
+ }, [inventoryId, search]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
@@ -138,7 +139,7 @@ function InventoryGroupsList({ i18n, location, match }) {
try {
const {
data: { count, results },
- } = await fetchGroups(inventoryId, location.search);
+ } = await fetchGroups(inventoryId, search);
setGroups(results);
setGroupCount(count);
} catch (error) {
@@ -263,4 +264,4 @@ function InventoryGroupsList({ i18n, location, match }) {
>
);
}
-export default withI18n()(withRouter(InventoryGroupsList));
+export default withI18n()(InventoryGroupsList);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx
index b0f4521c30..a10cd22160 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx
@@ -73,10 +73,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- }
- />,
+
+
+ ,
{
context: {
router: { history, route: { location: history.location } },
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx
index 4f73cb36a3..ef90103734 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx
@@ -1,18 +1,18 @@
import React from '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';
-function InventoryHostGroups({ location, match }) {
+function InventoryHostGroups() {
return (
{
- return ;
+ return ;
}}
/>
@@ -20,4 +20,4 @@ function InventoryHostGroups({ location, match }) {
}
export { InventoryHostGroups as _InventoryHostGroups };
-export default withI18n()(withRouter(InventoryHostGroups));
+export default withI18n()(InventoryHostGroups);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
index 3f14e3ae58..f4c8212e1e 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx
@@ -1,12 +1,22 @@
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 { t } from '@lingui/macro';
-import { getQSConfig, parseQueryString } from '@util/qs';
-import useRequest from '@util/useRequest';
-import { HostsAPI } from '@api';
+import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
+import useRequest, {
+ useDismissableError,
+ useDeleteItems,
+} from '@util/useRequest';
+import useSelected from '@util/useSelected';
+import { HostsAPI, InventoriesAPI } from '@api';
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';
const QS_CONFIG = getQSConfig('group', {
@@ -15,29 +25,36 @@ const QS_CONFIG = getQSConfig('group', {
order_by: 'name',
});
-function InventoryHostGroupsList({ i18n, location, match }) {
- const [selected, setSelected] = useState([]);
-
- const { hostId } = match.params;
+function InventoryHostGroupsList({ i18n }) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { hostId, id: invId } = useParams();
+ const { search } = useLocation();
const {
- result: { groups, itemCount },
+ result: { groups, itemCount, actions },
error: contentError,
isLoading,
request: fetchGroups,
} = useRequest(
useCallback(async () => {
- const params = parseQueryString(QS_CONFIG, location.search);
+ const params = parseQueryString(QS_CONFIG, search);
- const {
- data: { count, results },
- } = await HostsAPI.readGroups(hostId, params);
+ const [
+ {
+ data: { count, results },
+ },
+ actionsResponse,
+ ] = await Promise.all([
+ HostsAPI.readAllGroups(hostId, params),
+ HostsAPI.readGroupsOptions(hostId),
+ ]);
return {
- itemCount: count,
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: [],
itemCount: 0,
@@ -48,26 +65,68 @@ function InventoryHostGroupsList({ i18n, location, match }) {
fetchGroups();
}, [fetchGroups]);
- const handleSelectAll = isSelected => {
- setSelected(isSelected ? [...groups] : []);
- };
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ groups
+ );
- const handleSelect = row => {
- if (selected.some(s => s.id === row.id)) {
- setSelected(selected.filter(s => s.id !== row.id));
- } else {
- setSelected(selected.concat(row));
+ const {
+ isLoading: isDisassociateLoading,
+ deleteItems: disassociateHosts,
+ deletionError: disassociateError,
+ } = 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 =
- selected.length > 0 && selected.length === groups.length;
+ const fetchGroupsToAssociate = useCallback(
+ 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 (
<>
+ setSelected(isSelected ? [...groups] : [])
+ }
qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? [
+ setIsModalOpen(true)}
+ />,
+ ]
+ : []),
+ ,
+ ]}
/>
)}
+ emptyStateControls={
+ canAdd ? (
+ setIsModalOpen(true)} />
+ ) : null
+ }
/>
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ title={i18n._(t`Select Groups`)}
+ />
+ )}
+ {error && (
+
+ {associateError
+ ? i18n._(t`Failed to associate.`)
+ : i18n._(t`Failed to disassociate one or more groups.`)}
+
+
+ )}
>
);
}
-export default withI18n()(withRouter(InventoryHostGroupsList));
+export default withI18n()(InventoryHostGroupsList);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
index 4b4fa7e884..c20be91121 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx
@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import { HostsAPI } from '@api';
+import { HostsAPI, InventoriesAPI } from '@api';
import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('@api');
@@ -52,7 +52,7 @@ const mockGroups = [
id: 1,
},
user_capabilities: {
- delete: false,
+ delete: true,
edit: false,
},
},
@@ -63,7 +63,7 @@ describe('', () => {
let wrapper;
beforeEach(async () => {
- HostsAPI.readGroups.mockResolvedValue({
+ HostsAPI.readAllGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
@@ -82,10 +82,9 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
- }
- />,
+
+
+ ,
{
context: {
router: { history, route: { location: history.location } },
@@ -96,12 +95,17 @@ describe('', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
test('initially renders successfully', () => {
expect(wrapper.find('InventoryHostGroupsList').length).toBe(1);
});
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);
});
@@ -152,10 +156,108 @@ describe('', () => {
});
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 () => {
wrapper = mountWithContexts();
});
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();
+ });
+ 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);
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx
index fdf368e39d..3b82d513a5 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx
@@ -1,5 +1,5 @@
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 { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
@@ -20,7 +20,7 @@ const QS_CONFIG = getQSConfig('host', {
order_by: 'name',
});
-function InventoryHostList({ i18n, location, match }) {
+function InventoryHostList({ i18n }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
@@ -28,10 +28,12 @@ function InventoryHostList({ i18n, location, match }) {
const [hosts, setHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
+ const { id } = useParams();
+ const { search } = useLocation();
- const fetchHosts = (id, queryString) => {
+ const fetchHosts = (hostId, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
- return InventoriesAPI.readHosts(id, params);
+ return InventoriesAPI.readHosts(hostId, params);
};
useEffect(() => {
@@ -45,7 +47,7 @@ function InventoryHostList({ i18n, location, match }) {
data: { actions: optionActions },
},
] = await Promise.all([
- fetchHosts(match.params.id, location.search),
+ fetchHosts(id, search),
InventoriesAPI.readOptions(),
]);
@@ -60,7 +62,7 @@ function InventoryHostList({ i18n, location, match }) {
}
fetchData();
- }, [match.params.id, location]);
+ }, [id, search]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
@@ -86,7 +88,7 @@ function InventoryHostList({ i18n, location, match }) {
try {
const {
data: { count, results },
- } = await fetchHosts(match.params.id, location.search);
+ } = await fetchHosts(id, search);
setHosts(results);
setHostCount(count);
@@ -143,7 +145,7 @@ function InventoryHostList({ i18n, location, match }) {
? [
,
]
: []),
@@ -160,8 +162,8 @@ function InventoryHostList({ i18n, location, match }) {
row.id === o.id)}
onSelect={() => handleSelect(o)}
/>
@@ -170,7 +172,7 @@ function InventoryHostList({ i18n, location, match }) {
canAdd && (
)
}
@@ -190,4 +192,4 @@ function InventoryHostList({ i18n, location, match }) {
);
}
-export default withI18n()(withRouter(InventoryHostList));
+export default withI18n()(InventoryHostList);