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';