mirror of
https://github.com/ansible/awx.git
synced 2026-03-10 22:19:28 -02:30
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:
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
393
awx/ui_next/src/components/AssociateModal/data.hosts.json
Normal file
393
awx/ui_next/src/components/AssociateModal/data.hosts.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
awx/ui_next/src/components/AssociateModal/index.js
Normal file
1
awx/ui_next/src/components/AssociateModal/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './AssociateModal';
|
||||||
1
awx/ui_next/src/components/DisassociateButton/index.js
Normal file
1
awx/ui_next/src/components/DisassociateButton/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './DisassociateButton';
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 group’s
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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 } } }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 group’s
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user