From ab8726dafa8f137843594ead7f2a5c027ceec807 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 1 Apr 2020 12:34:58 -0400 Subject: [PATCH 1/7] move associate modal and disassociate button up to components for use across screens --- .../AssociateModal}/AssociateModal.jsx | 0 .../AssociateModal}/AssociateModal.test.jsx | 2 +- .../components/AssociateModal/data.hosts.json | 393 ++++++++++++++++++ .../src/components/AssociateModal/index.js | 1 + .../DisassociateButton.jsx | 0 .../DisassociateButton.test.jsx | 0 .../components/DisassociateButton/index.js | 1 + .../InventoryGroupHostList.jsx | 4 +- 8 files changed, 398 insertions(+), 3 deletions(-) rename awx/ui_next/src/{screens/Inventory/InventoryGroupHosts => components/AssociateModal}/AssociateModal.jsx (100%) rename awx/ui_next/src/{screens/Inventory/InventoryGroupHosts => components/AssociateModal}/AssociateModal.test.jsx (97%) create mode 100644 awx/ui_next/src/components/AssociateModal/data.hosts.json create mode 100644 awx/ui_next/src/components/AssociateModal/index.js rename awx/ui_next/src/{screens/Inventory/InventoryGroupHosts => components/DisassociateButton}/DisassociateButton.jsx (100%) rename awx/ui_next/src/{screens/Inventory/InventoryGroupHosts => components/DisassociateButton}/DisassociateButton.test.jsx (100%) create mode 100644 awx/ui_next/src/components/DisassociateButton/index.js 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/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, From cc4c514103d0b9de18a9a787a31d02740bcad3bd Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 2 Apr 2020 12:12:43 -0400 Subject: [PATCH 2/7] add association and disassociation of groups on invhostgroups/hostgroups lists --- awx/ui_next/src/api/models/Hosts.js | 14 ++ .../screens/Host/HostGroups/HostGroups.jsx | 6 +- .../Host/HostGroups/HostGroups.test.jsx | 6 +- .../Host/HostGroups/HostGroupsList.jsx | 152 +++++++++++++++--- .../Host/HostGroups/HostGroupsList.test.jsx | 120 +++++++++++++- .../InventoryHostGroupsList.jsx | 151 ++++++++++++++--- .../InventoryHostGroupsList.test.jsx | 105 +++++++++++- 7 files changed, 499 insertions(+), 55 deletions(-) diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js index 2d13b00072..72ee919dae 100644 --- a/awx/ui_next/src/api/models/Hosts.js +++ b/awx/ui_next/src/api/models/Hosts.js @@ -1,4 +1,5 @@ import Base from '../Base'; +import { TintSlashIcon } from '@patternfly/react-icons'; class Hosts extends Base { constructor(http) { @@ -8,6 +9,8 @@ class Hosts extends Base { this.readFacts = this.readFacts.bind(this); this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); + this.associateGroup = this.associateGroup.bind(this); + this.disassociateGroup = this.disassociateGroup.bind(this); } readFacts(id) { @@ -21,6 +24,17 @@ class Hosts extends Base { 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/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx index 70a51dc51b..dca2320b88 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx @@ -5,14 +5,16 @@ import { Switch, Route, withRouter } from 'react-router-dom'; import HostGroupsList from './HostGroupsList'; -function HostGroups({ location, match }) { +function HostGroups({ location, match, host }) { return ( { - return ; + return ( + + ); }} /> 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..9576f710b6 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -2,11 +2,21 @@ import React, { useState, useEffect, useCallback } from 'react'; import { withRouter } 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,13 +25,14 @@ const QS_CONFIG = getQSConfig('group', { order_by: 'name', }); -function HostGroupsList({ i18n, location, match }) { - const [selected, setSelected] = useState([]); +function HostGroupsList({ i18n, location, match, host }) { + const [isModalOpen, setIsModalOpen] = useState(false); const hostId = match.params.id; + const invId = host.summary_fields.inventory.id; const { - result: { groups, itemCount }, + result: { groups, itemCount, actions }, error: contentError, isLoading, request: fetchGroups, @@ -29,13 +40,20 @@ function HostGroupsList({ i18n, location, match }) { useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { count, results }, - } = await HostsAPI.readGroups(hostId, params); + const [ + { + data: { count, results }, + }, + actionsResponse, + ] = await Promise.all([ + HostsAPI.readGroups(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 { @@ -48,26 +66,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.`)} + + + )} ); } 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..c748263eaf 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, }, }, @@ -82,7 +90,10 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - } />, + } + />, { context: { router: { history, route: { location: history.location } }, @@ -93,6 +104,11 @@ 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); }); @@ -151,8 +167,104 @@ describe('', () => { test('should show content error when api throws error on initial render', async () => { HostsAPI.readGroups.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.readGroups).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.readGroups).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/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index 3f14e3ae58..7d260f7782 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -2,11 +2,21 @@ import React, { useState, useEffect, useCallback } from 'react'; import { withRouter } 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', { @@ -16,12 +26,12 @@ const QS_CONFIG = getQSConfig('group', { }); function InventoryHostGroupsList({ i18n, location, match }) { - const [selected, setSelected] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); - const { hostId } = match.params; + const { hostId, id: invId } = match.params; const { - result: { groups, itemCount }, + result: { groups, itemCount, actions }, error: contentError, isLoading, request: fetchGroups, @@ -29,13 +39,20 @@ function InventoryHostGroupsList({ i18n, location, match }) { useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { count, results }, - } = await HostsAPI.readGroups(hostId, params); + const [ + { + data: { count, results }, + }, + actionsResponse, + ] = await Promise.all([ + HostsAPI.readGroups(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 { @@ -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.`)} + + + )} ); } 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..c31f996c10 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, }, }, @@ -96,6 +96,11 @@ 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); }); @@ -158,4 +163,100 @@ describe('', () => { }); 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.readGroups).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.readGroups).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); + }); }); From 4e64b17712db27aba21cc7848120a4b4ee167527 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 2 Apr 2020 13:27:51 -0400 Subject: [PATCH 3/7] update hosts groups api GET to all_groups --- awx/ui_next/src/api/models/Hosts.js | 6 +++--- .../src/screens/Host/HostGroups/HostGroupsList.jsx | 2 +- .../screens/Host/HostGroups/HostGroupsList.test.jsx | 12 +++++++----- .../InventoryHostGroups/InventoryHostGroupsList.jsx | 2 +- .../InventoryHostGroupsList.test.jsx | 12 +++++++----- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js index 72ee919dae..f9f5fe5542 100644 --- a/awx/ui_next/src/api/models/Hosts.js +++ b/awx/ui_next/src/api/models/Hosts.js @@ -7,7 +7,7 @@ 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); @@ -17,8 +17,8 @@ class Hosts extends Base { 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) { diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx index 9576f710b6..4da7fdb023 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -46,7 +46,7 @@ function HostGroupsList({ i18n, location, match, host }) { }, actionsResponse, ] = await Promise.all([ - HostsAPI.readGroups(hostId, params), + HostsAPI.readAllGroups(hostId, params), HostsAPI.readGroupsOptions(hostId), ]); 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 c748263eaf..4bb2a89b7a 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx @@ -71,7 +71,7 @@ describe('', () => { let wrapper; beforeEach(async () => { - HostsAPI.readGroups.mockResolvedValue({ + HostsAPI.readAllGroups.mockResolvedValue({ data: { count: mockGroups.length, results: mockGroups, @@ -114,7 +114,7 @@ describe('', () => { }); 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); }); @@ -165,7 +165,9 @@ 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(); }); @@ -224,7 +226,7 @@ describe('', () => { test('expected api calls are made for multi-disassociation', async () => { expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0); - expect(HostsAPI.readGroups).toHaveBeenCalledTimes(1); + expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(1); expect(wrapper.find('DataListCheck').length).toBe(3); wrapper.find('DataListCheck').forEach(el => { expect(el.props().checked).toBe(false); @@ -246,7 +248,7 @@ describe('', () => { .simulate('click'); }); expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3); - expect(HostsAPI.readGroups).toHaveBeenCalledTimes(2); + expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(2); }); test('should show error modal for failed disassociation', async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index 7d260f7782..e049b1d596 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -45,7 +45,7 @@ function InventoryHostGroupsList({ i18n, location, match }) { }, actionsResponse, ] = await Promise.all([ - HostsAPI.readGroups(hostId, params), + HostsAPI.readAllGroups(hostId, params), HostsAPI.readGroupsOptions(hostId), ]); 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 c31f996c10..8347494c2d 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx @@ -63,7 +63,7 @@ describe('', () => { let wrapper; beforeEach(async () => { - HostsAPI.readGroups.mockResolvedValue({ + HostsAPI.readAllGroups.mockResolvedValue({ data: { count: mockGroups.length, results: mockGroups, @@ -106,7 +106,7 @@ describe('', () => { }); 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); }); @@ -157,7 +157,9 @@ 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(); }); @@ -216,7 +218,7 @@ describe('', () => { test('expected api calls are made for multi-disassociation', async () => { expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(0); - expect(HostsAPI.readGroups).toHaveBeenCalledTimes(1); + expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(1); expect(wrapper.find('DataListCheck').length).toBe(3); wrapper.find('DataListCheck').forEach(el => { expect(el.props().checked).toBe(false); @@ -238,7 +240,7 @@ describe('', () => { .simulate('click'); }); expect(HostsAPI.disassociateGroup).toHaveBeenCalledTimes(3); - expect(HostsAPI.readGroups).toHaveBeenCalledTimes(2); + expect(HostsAPI.readAllGroups).toHaveBeenCalledTimes(2); }); test('should show error modal for failed disassociation', async () => { From 7dbde8d82c091f1c3f6088603260cc6d5ffa78fc Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 6 Apr 2020 10:14:59 -0400 Subject: [PATCH 4/7] fix linting errors and add note to host groups disassocation modal --- awx/ui_next/src/api/models/Hosts.js | 1 - awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx | 6 ++++++ .../InventoryHostGroups/InventoryHostGroupsList.jsx | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js index f9f5fe5542..ae90bf2826 100644 --- a/awx/ui_next/src/api/models/Hosts.js +++ b/awx/ui_next/src/api/models/Hosts.js @@ -1,5 +1,4 @@ import Base from '../Base'; -import { TintSlashIcon } from '@patternfly/react-icons'; class Hosts extends Base { constructor(http) { diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx index 4da7fdb023..22c2901358 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -186,6 +186,12 @@ function HostGroupsList({ i18n, location, match, host }) { 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. + `)} />, ]} /> diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index e049b1d596..04441c5070 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -184,6 +184,12 @@ function InventoryHostGroupsList({ i18n, location, match }) { 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. + `)} />, ]} /> From ecd1d09c9a198ff3a1f51704db46a0914272e9c3 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 6 Apr 2020 13:24:35 -0400 Subject: [PATCH 5/7] add breadcrumb config for inv host facts and groups --- awx/ui_next/src/screens/Inventory/Inventories.jsx | 2 ++ 1 file changed, 2 insertions(+) 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`), From 2021c2a596ac43ab795d01c8bd68059a9801a7b1 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 6 Apr 2020 13:54:53 -0400 Subject: [PATCH 6/7] remove unnecessary eslint ignore comics, replace react router use with hooks where possible in inventories --- .../screens/Host/HostGroups/HostGroups.jsx | 10 +++---- .../Host/HostGroups/HostGroupsList.jsx | 13 +++++----- .../InventoryGroups/InventoryGroups.jsx | 8 +++--- .../InventoryGroups/InventoryGroupsList.jsx | 15 ++++++----- .../InventoryHostGroups.jsx | 8 +++--- .../InventoryHostGroupsList.jsx | 14 +++++----- .../InventoryHosts/InventoryHostList.jsx | 26 ++++++++++--------- 7 files changed, 48 insertions(+), 46 deletions(-) diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx index dca2320b88..ec4fe08ceb 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx @@ -1,20 +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, host }) { +function HostGroups({ host }) { return ( { - return ( - - ); + return ; }} /> @@ -22,4 +20,4 @@ function HostGroups({ location, match, host }) { } export { HostGroups as _HostGroups }; -export default withI18n()(withRouter(HostGroups)); +export default withI18n()(HostGroups); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx index 22c2901358..aa813b24a3 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -1,5 +1,5 @@ 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, mergeParams } from '@util/qs'; @@ -25,10 +25,11 @@ const QS_CONFIG = getQSConfig('group', { order_by: 'name', }); -function HostGroupsList({ i18n, location, match, host }) { +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 { @@ -38,7 +39,7 @@ function HostGroupsList({ i18n, location, match, host }) { request: fetchGroups, } = useRequest( useCallback(async () => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(QS_CONFIG, search); const [ { @@ -55,7 +56,7 @@ function HostGroupsList({ i18n, location, match, host }) { itemCount: count, actions: actionsResponse.data.actions, }; - }, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps + }, [hostId, search]), { groups: [], itemCount: 0, @@ -228,4 +229,4 @@ function HostGroupsList({ i18n, location, match, host }) { ); } -export default withI18n()(withRouter(HostGroupsList)); +export default withI18n()(HostGroupsList); 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/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 04441c5070..f4c8212e1e 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -1,5 +1,5 @@ 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, mergeParams } from '@util/qs'; @@ -25,10 +25,10 @@ const QS_CONFIG = getQSConfig('group', { order_by: 'name', }); -function InventoryHostGroupsList({ i18n, location, match }) { +function InventoryHostGroupsList({ i18n }) { const [isModalOpen, setIsModalOpen] = useState(false); - - const { hostId, id: invId } = match.params; + const { hostId, id: invId } = useParams(); + const { search } = useLocation(); const { result: { groups, itemCount, actions }, @@ -37,7 +37,7 @@ function InventoryHostGroupsList({ i18n, location, match }) { request: fetchGroups, } = useRequest( useCallback(async () => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(QS_CONFIG, search); const [ { @@ -54,7 +54,7 @@ function InventoryHostGroupsList({ i18n, location, match }) { 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, @@ -226,4 +226,4 @@ function InventoryHostGroupsList({ i18n, location, match }) { ); } -export default withI18n()(withRouter(InventoryHostGroupsList)); +export default withI18n()(InventoryHostGroupsList); 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); From ce30594b30094be74ba8746f28a5980c400ad0e8 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 6 Apr 2020 15:05:11 -0400 Subject: [PATCH 7/7] update inventory and host routes to being child-based instead of render prop based --- .../src/screens/Host/HostGroups/HostGroupsList.test.jsx | 7 +++---- .../Inventory/InventoryGroup/InventoryGroup.test.jsx | 9 +++------ .../InventoryGroupAdd/InventoryGroupAdd.test.jsx | 7 +++---- .../InventoryGroupDetail/InventoryGroupDetail.test.jsx | 9 +++------ .../InventoryGroupEdit/InventoryGroupEdit.test.jsx | 7 +++---- .../InventoryGroups/InventoryGroupsList.test.jsx | 7 +++---- .../InventoryHostGroups/InventoryHostGroupsList.test.jsx | 7 +++---- 7 files changed, 21 insertions(+), 32 deletions(-) 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 4bb2a89b7a..82f3bf61ab 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx @@ -90,10 +90,9 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - } - />, + + + , { context: { router: { history, route: { location: history.location } }, 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/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/InventoryHostGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx index 8347494c2d..c20be91121 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx @@ -82,10 +82,9 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - } - />, + + + , { context: { router: { history, route: { location: history.location } },