{}}
+ />
+ );
+ expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
index eb512861e6..53f1f75774 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx
@@ -1,10 +1,259 @@
-import React, { Component } from 'react';
-import { CardBody } from '@patternfly/react-core';
+import React, { useState, useEffect } from 'react';
+import { TrashAltIcon } from '@patternfly/react-icons';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import { InventoriesAPI, GroupsAPI } from '@api';
+import { Button, Tooltip } from '@patternfly/react-core';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
+import DataListToolbar from '@components/DataListToolbar';
+import PaginatedDataList, {
+ ToolbarAddButton,
+} from '@components/PaginatedDataList';
+import styled from 'styled-components';
+import InventoryGroupItem from './InventoryGroupItem';
+import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
-class InventoryGroups extends Component {
- render() {
- return Coming soon :);
+const QS_CONFIG = getQSConfig('host', {
+ page: 1,
+ page_size: 20,
+ order_by: 'name',
+});
+
+const DeleteButton = styled(Button)`
+ padding: 5px 8px;
+
+ &:hover {
+ background-color: #d9534f;
+ color: white;
}
+
+ &[disabled] {
+ color: var(--pf-c-button--m-plain--Color);
+ pointer-events: initial;
+ cursor: not-allowed;
+ }
+`;
+
+function cannotDelete(item) {
+ return !item.summary_fields.user_capabilities.delete;
}
-export default InventoryGroups;
+const useModal = () => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ function toggleModal() {
+ setIsModalOpen(!isModalOpen);
+ }
+
+ return {
+ isModalOpen,
+ toggleModal,
+ };
+};
+
+function InventoryGroups({ i18n, location, match }) {
+ const [actions, setActions] = useState(null);
+ const [contentError, setContentError] = useState(null);
+ const [deletionError, setDeletionError] = useState(null);
+ const [groupCount, setGroupCount] = useState(0);
+ const [groups, setGroups] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [selected, setSelected] = useState([]);
+ const { isModalOpen, toggleModal } = useModal();
+
+ const inventoryId = match.params.id;
+
+ const fetchGroups = (id, queryString) => {
+ const params = parseQueryString(QS_CONFIG, queryString);
+ return InventoriesAPI.readGroups(id, params);
+ };
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const [
+ {
+ data: { count, results },
+ },
+ {
+ data: { actions: optionActions },
+ },
+ ] = await Promise.all([
+ fetchGroups(inventoryId, location.search),
+ InventoriesAPI.readGroupsOptions(inventoryId),
+ ]);
+
+ setGroups(results);
+ setGroupCount(count);
+ setActions(optionActions);
+ } catch (error) {
+ setContentError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchData();
+ }, [inventoryId, location]);
+
+ 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 renderTooltip = () => {
+ const itemsUnableToDelete = selected
+ .filter(cannotDelete)
+ .map(item => item.name)
+ .join(', ');
+
+ if (selected.some(cannotDelete)) {
+ return (
+
+ {i18n._(
+ t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
+ )}
+
+ );
+ }
+ if (selected.length) {
+ return i18n._(t`Delete`);
+ }
+ return i18n._(t`Select a row to delete`);
+ };
+
+ const promoteGroups = list => {
+ const promotePromises = Object.keys(list)
+ .filter(groupId => list[groupId] === 'promote')
+ .map(groupId => InventoriesAPI.promoteGroup(inventoryId, +groupId));
+
+ return Promise.all(promotePromises);
+ };
+
+ const deleteGroups = list => {
+ const deletePromises = Object.keys(list)
+ .filter(groupId => list[groupId] === 'delete')
+ .map(groupId => GroupsAPI.destroy(+groupId));
+
+ return Promise.all(deletePromises);
+ };
+
+ const handleDelete = async list => {
+ setIsLoading(true);
+
+ try {
+ await Promise.all([promoteGroups(list), deleteGroups(list)]);
+ } catch (error) {
+ setDeletionError(error);
+ } finally {
+ toggleModal();
+ setSelected([]);
+
+ try {
+ const {
+ data: { count, results },
+ } = await fetchGroups(inventoryId, location.search);
+
+ setGroups(results);
+ setGroupCount(count);
+ } catch (error) {
+ setContentError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ const canAdd =
+ actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
+ const isAllSelected =
+ selected.length > 0 && selected.length === groups.length;
+
+ return (
+ <>
+ (
+ row.id === item.id)}
+ onSelect={() => handleSelect(item)}
+ />
+ )}
+ renderToolbar={props => (
+
+
+
+
+
+
+ ,
+ canAdd && (
+
+ ),
+ ]}
+ />
+ )}
+ emptyStateControls={
+ canAdd && (
+
+ )
+ }
+ />
+ {deletionError && (
+ setDeletionError(null)}
+ >
+ {i18n._(t`Failed to delete one or more groups.`)}
+
+
+ )}
+
+ >
+ );
+}
+
+export default withI18n()(withRouter(InventoryGroups));
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx
new file mode 100644
index 0000000000..abf12234fe
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx
@@ -0,0 +1,207 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { MemoryRouter, Route } from 'react-router-dom';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { InventoriesAPI, GroupsAPI } from '@api';
+import InventoryGroups from './InventoryGroups';
+
+jest.mock('@api');
+
+const mockGroups = [
+ {
+ id: 1,
+ type: 'group',
+ name: 'foo',
+ inventory: 1,
+ url: '/api/v2/groups/1',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ },
+ },
+ },
+ {
+ id: 2,
+ type: 'group',
+ name: 'bar',
+ inventory: 1,
+ url: '/api/v2/groups/2',
+ summary_fields: {
+ user_capabilities: {
+ delete: true,
+ edit: true,
+ },
+ },
+ },
+ {
+ id: 3,
+ type: 'group',
+ name: 'baz',
+ inventory: 1,
+ url: '/api/v2/groups/3',
+ summary_fields: {
+ user_capabilities: {
+ delete: false,
+ edit: false,
+ },
+ },
+ },
+];
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(async () => {
+ InventoriesAPI.readGroups.mockResolvedValue({
+ data: {
+ count: mockGroups.length,
+ results: mockGroups,
+ },
+ });
+ InventoriesAPI.readGroupsOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ },
+ });
+
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ }
+ />
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders successfully', () => {
+ expect(wrapper.find('InventoryGroups').length).toBe(1);
+ });
+
+ test('should fetch groups from api and render them in the list', async () => {
+ expect(InventoriesAPI.readGroups).toHaveBeenCalled();
+ expect(wrapper.find('InventoryGroupItem').length).toBe(3);
+ });
+
+ test('should check and uncheck the row item', async () => {
+ expect(
+ wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
+ ).toBe(false);
+
+ await act(async () => {
+ wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
+ true
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
+ ).toBe(true);
+
+ await act(async () => {
+ wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
+ false
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
+ ).toBe(false);
+ });
+
+ test('should check all row items when select all is checked', async () => {
+ wrapper.find('PFDataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('PFDataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(true);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(false);
+ });
+ wrapper.update();
+ wrapper.find('PFDataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ });
+
+ test('should show content error when api throws error on initial render', async () => {
+ InventoriesAPI.readGroupsOptions.mockImplementation(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+
+ test('should show content error if groups are not successfully fetched from api', async () => {
+ InventoriesAPI.readGroups.mockImplementation(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
+ });
+ await waitForElement(
+ wrapper,
+ 'InventoryGroupsDeleteModal',
+ el => el.props().isModalOpen === true
+ );
+ await act(async () => {
+ wrapper
+ .find('ModalBoxFooter Button[aria-label="Delete"]')
+ .invoke('onClick')();
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+
+ test('should show error modal when group is not successfully deleted from api', async () => {
+ GroupsAPI.destroy.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/groups/1',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ await act(async () => {
+ wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
+ });
+ await waitForElement(
+ wrapper,
+ 'InventoryGroupsDeleteModal',
+ el => el.props().isModalOpen === true
+ );
+ await act(async () => {
+ wrapper
+ .find('ModalBoxFooter Button[aria-label="Delete"]')
+ .invoke('onClick')();
+ });
+ await waitForElement(wrapper, { title: 'Error!', variant: 'danger' });
+ await act(async () => {
+ wrapper.find('ModalBoxCloseButton').invoke('onClose')();
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.jsx
index 38711ac149..95e174a1fd 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.jsx
@@ -20,7 +20,7 @@ const mockHost = {
},
};
-describe.only('', () => {
+describe('', () => {
beforeEach(() => {
toggleHost = jest.fn();
});
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx
new file mode 100644
index 0000000000..d0ac798872
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx
@@ -0,0 +1,144 @@
+import React, { useState, useEffect } from 'react';
+import ReactDOM from 'react-dom';
+import AlertModal from '@components/AlertModal';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Button, Radio } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+const ListItem = styled.div`
+ padding: 24px 1px;
+
+ dl {
+ display: flex;
+ font-weight: 600;
+ }
+ dt {
+ color: var(--pf-global--danger-color--100);
+ margin-right: 10px;
+ }
+ .pf-c-radio {
+ margin-top: 10px;
+ }
+`;
+
+const ContentWrapper = styled.div`
+ ${ListItem} + ${ListItem} {
+ border-top-width: 1px;
+ border-top-style: solid;
+ border-top-color: #d7d7d7;
+ }
+ ${ListItem}:last-child {
+ padding-bottom: 0;
+ }
+ `;
+
+const InventoryGroupsDeleteModal = ({
+ onClose,
+ onDelete,
+ isModalOpen,
+ groups,
+ i18n,
+}) => {
+ const [deleteList, setDeleteList] = useState([]);
+
+ useEffect(() => {
+ const groupIds = groups.reduce((obj, group) => {
+ if (group.total_groups > 0 || group.total_hosts > 0) {
+ return { ...obj, [group.id]: null };
+ }
+ return { ...obj, [group.id]: 'delete' };
+ }, {});
+
+ setDeleteList(groupIds);
+ }, [groups]);
+
+ const handleChange = (groupId, radioOption) => {
+ setDeleteList({ ...deleteList, [groupId]: radioOption });
+ };
+
+ const content = groups
+ .map(group => {
+ if (group.total_groups > 0 || group.total_hosts > 0) {
+ return (
+
+
+ - {group.name}
+ -
+ {i18n._(
+ t`(${group.total_groups} Groups and ${group.total_hosts} Hosts)`
+ )}
+
+
+ handleChange(group.id, 'delete')}
+ />
+ handleChange(group.id, 'promote')}
+ />
+
+ );
+ }
+ return (
+
+
+ - {group.name}
+ - {i18n._(t`(No Child Groups or Hosts)`)}
+
+
+ );
+ })
+ .reduce((array, el) => {
+ return array.concat(el);
+ }, []);
+
+ return ReactDOM.createPortal(
+ 1 ? i18n._(t`Delete Groups`) : i18n._(t`Delete Group`)
+ }
+ onClose={onClose}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {i18n._(
+ t`Are you sure you want to delete the ${
+ groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`)
+ } below?`
+ )}
+ {content}
+ ,
+ document.body
+ );
+};
+
+export default withI18n()(InventoryGroupsDeleteModal);
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index a519b7ab08..4713e0dcfd 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -229,3 +229,23 @@ export const User = shape({
ldap_dn: string,
last_login: string,
});
+
+export const Group = shape({
+ id: number.isRequired,
+ type: oneOf(['group']),
+ url: string,
+ related: shape({}),
+ summary_fields: shape({}),
+ created: string,
+ modified: string,
+ name: string.isRequired,
+ description: string,
+ inventory: number,
+ variables: string,
+ has_active_failures: bool,
+ total_hosts: number,
+ hosts_with_active_failures: number,
+ total_groups: number,
+ groups_with_active_failures: number,
+ has_inventory_sources: bool,
+});