diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js index 3d938fe1f3..2d13b00072 100644 --- a/awx/ui_next/src/api/models/Hosts.js +++ b/awx/ui_next/src/api/models/Hosts.js @@ -4,11 +4,23 @@ class Hosts extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/hosts/'; + + this.readFacts = this.readFacts.bind(this); + this.readGroups = this.readGroups.bind(this); + this.readGroupsOptions = this.readGroupsOptions.bind(this); } readFacts(id) { return this.http.get(`${this.baseUrl}${id}/ansible_facts/`); } + + readGroups(id, params) { + return this.http.get(`${this.baseUrl}${id}/groups/`, { params }); + } + + readGroupsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/groups/`); + } } export default Hosts; diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.jsx new file mode 100644 index 0000000000..35bb495e82 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { bool, func, number, oneOfType, string } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Group } from '@types'; + +import { + Button, + DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; +import DataListCell from '@components/DataListCell'; + +import { Link } from 'react-router-dom'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +function HostGroupItem({ i18n, group, inventoryId, isSelected, onSelect }) { + const labelId = `check-action-${group.id}`; + const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; + const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; + + return ( + + + + + + {group.name} + + , + ]} + /> + + {group.summary_fields.user_capabilities.edit && ( + + + + )} + + + + ); +} + +HostGroupItem.propTypes = { + group: Group.isRequired, + inventoryId: oneOfType([number, string]).isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(HostGroupItem); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.test.jsx new file mode 100644 index 0000000000..ede2d506f3 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupItem.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import HostGroupItem from './HostGroupItem'; + +describe('', () => { + let wrapper; + const mockGroup = { + id: 2, + type: 'group', + name: 'foo', + inventory: 1, + summary_fields: { + user_capabilities: { + edit: true, + }, + }, + }; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('HostGroupItem').length).toBe(1); + }); + + test('edit button should be shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button should be hidden from users without edit capabilities', () => { + const copyMockGroup = Object.assign({}, mockGroup); + copyMockGroup.summary_fields.user_capabilities.edit = false; + + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx index 2fad6886cd..70a51dc51b 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx @@ -1,10 +1,23 @@ -import React, { Component } from 'react'; -import { CardBody } from '@components/Card'; +import React from 'react'; +import { withI18n } from '@lingui/react'; -class HostGroups extends Component { - render() { - return Coming soon :); - } +import { Switch, Route, withRouter } from 'react-router-dom'; + +import HostGroupsList from './HostGroupsList'; + +function HostGroups({ location, match }) { + return ( + + { + return ; + }} + /> + + ); } -export default HostGroups; +export { HostGroups as _HostGroups }; +export default withI18n()(withRouter(HostGroups)); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx new file mode 100644 index 0000000000..46aadcf637 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import HostGroups from './HostGroups'; + +jest.mock('@api'); + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/hosts/1/groups'], + }); + const host = { id: 1, name: 'Foo' }; + + await act(async () => { + wrapper = mountWithContexts( + {}} host={host} />, + + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('HostGroupsList').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx new file mode 100644 index 0000000000..371329aa97 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -0,0 +1,119 @@ +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 DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList from '@components/PaginatedDataList'; +import HostGroupItem from './HostGroupItem'; + +const QS_CONFIG = getQSConfig('group', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function HostGroupsList({ i18n, location, match }) { + const [selected, setSelected] = useState([]); + + const hostId = match.params.id; + + const { + result: { groups, itemCount }, + error: contentError, + isLoading, + request: fetchGroups, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const { + data: { count, results }, + } = await HostsAPI.readGroups(hostId, params); + + return { + itemCount: count, + groups: results, + }; + }, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps + { + groups: [], + itemCount: 0, + } + ); + + useEffect(() => { + fetchGroups(); + }, [fetchGroups]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...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 isAllSelected = + selected.length > 0 && selected.length === groups.length; + + return ( + <> + ( + row.id === item.id)} + onSelect={() => handleSelect(item)} + /> + )} + renderToolbar={props => ( + + )} + /> + + ); +} +export default withI18n()(withRouter(HostGroupsList)); diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx new file mode 100644 index 0000000000..d472534e20 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.test.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +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 HostGroupsList from './HostGroupsList'; + +jest.mock('@api'); + +const mockGroups = [ + { + id: 1, + type: 'group', + name: 'foo', + inventory: 1, + url: '/api/v2/groups/1', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 2, + type: 'group', + name: 'bar', + inventory: 1, + url: '/api/v2/groups/2', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 3, + type: 'group', + name: 'baz', + inventory: 1, + url: '/api/v2/groups/3', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: false, + edit: false, + }, + }, + }, +]; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + HostsAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + HostsAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/hosts/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + } />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('HostGroupsList').length).toBe(1); + }); + + test('should fetch groups from api and render them in the list', async () => { + expect(HostsAPI.readGroups).toHaveBeenCalled(); + expect(wrapper.find('HostGroupItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + + await act(async () => { + wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(true); + + await act(async () => { + wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')( + false + ); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + 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); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + 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(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx index f8db7e74cf..df994b33e6 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx @@ -23,6 +23,7 @@ import JobList from '@components/JobList'; import InventoryHostDetail from '../InventoryHostDetail'; import InventoryHostEdit from '../InventoryHostEdit'; import InventoryHostFacts from '../InventoryHostFacts'; +import InventoryHostGroups from '../InventoryHostGroups'; function InventoryHost({ i18n, setBreadcrumb, inventory }) { const location = useLocation(); @@ -147,6 +148,12 @@ function InventoryHost({ i18n, setBreadcrumb, inventory }) { > + + + + + + + + {group.name} + + , + ]} + /> + + {group.summary_fields.user_capabilities.edit && ( + + + + )} + + + + ); +} + +InventoryHostGroupItem.propTypes = { + group: Group.isRequired, + inventoryId: oneOfType([number, string]).isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryHostGroupItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.test.jsx new file mode 100644 index 0000000000..9a06f73336 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryHostGroupItem from './InventoryHostGroupItem'; + +describe('', () => { + let wrapper; + const mockGroup = { + id: 2, + type: 'group', + name: 'foo', + inventory: 1, + summary_fields: { + user_capabilities: { + edit: true, + }, + }, + }; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('InventoryHostGroupItem').length).toBe(1); + }); + + test('edit button should be shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button should be hidden from users without edit capabilities', () => { + const copyMockGroup = Object.assign({}, mockGroup); + copyMockGroup.summary_fields.user_capabilities.edit = false; + + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx new file mode 100644 index 0000000000..4f73cb36a3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; + +import { Switch, Route, withRouter } from 'react-router-dom'; + +import InventoryHostGroupsList from './InventoryHostGroupsList'; + +function InventoryHostGroups({ location, match }) { + return ( + + { + return ; + }} + /> + + ); +} + +export { InventoryHostGroups as _InventoryHostGroups }; +export default withI18n()(withRouter(InventoryHostGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.test.jsx new file mode 100644 index 0000000000..9ee649b1f2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroups.test.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import InventoryHostGroups from './InventoryHostGroups'; + +jest.mock('@api'); + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/1/groups'], + }); + + await act(async () => { + wrapper = mountWithContexts(, { + context: { + router: { history, route: { location: history.location } }, + }, + }); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('InventoryHostGroupsList').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 new file mode 100644 index 0000000000..3f14e3ae58 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -0,0 +1,118 @@ +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 DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList from '@components/PaginatedDataList'; +import InventoryHostGroupItem from './InventoryHostGroupItem'; + +const QS_CONFIG = getQSConfig('group', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function InventoryHostGroupsList({ i18n, location, match }) { + const [selected, setSelected] = useState([]); + + const { hostId } = match.params; + + const { + result: { groups, itemCount }, + error: contentError, + isLoading, + request: fetchGroups, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const { + data: { count, results }, + } = await HostsAPI.readGroups(hostId, params); + + return { + itemCount: count, + groups: results, + }; + }, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps + { + groups: [], + itemCount: 0, + } + ); + + useEffect(() => { + fetchGroups(); + }, [fetchGroups]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...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 isAllSelected = + selected.length > 0 && selected.length === groups.length; + + return ( + <> + ( + row.id === item.id)} + onSelect={() => handleSelect(item)} + /> + )} + renderToolbar={props => ( + + )} + /> + + ); +} +export default withI18n()(withRouter(InventoryHostGroupsList)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx new file mode 100644 index 0000000000..4b4fa7e884 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.test.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +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 InventoryHostGroupsList from './InventoryHostGroupsList'; + +jest.mock('@api'); + +const mockGroups = [ + { + id: 1, + type: 'group', + name: 'foo', + inventory: 1, + url: '/api/v2/groups/1', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 2, + type: 'group', + name: 'bar', + inventory: 1, + url: '/api/v2/groups/2', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 3, + type: 'group', + name: 'baz', + inventory: 1, + url: '/api/v2/groups/3', + summary_fields: { + inventory: { + id: 1, + }, + user_capabilities: { + delete: false, + edit: false, + }, + }, + }, +]; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + HostsAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + HostsAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('InventoryHostGroupsList').length).toBe(1); + }); + + test('should fetch groups from api and render them in the list', async () => { + expect(HostsAPI.readGroups).toHaveBeenCalled(); + expect(wrapper.find('InventoryHostGroupItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + + await act(async () => { + wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(true); + + await act(async () => { + wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')( + false + ); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + 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); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + 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(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/index.js new file mode 100644 index 0000000000..b7644a3611 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHostGroups';