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