From 5d1f322cd1777bc92f99601e74ee8f188df268bd Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 26 Nov 2019 15:05:26 -0500 Subject: [PATCH] Add Inventory Groups list --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Groups.js | 10 + awx/ui_next/src/api/models/Inventories.js | 19 ++ awx/ui_next/src/app.scss | 1 + .../InventoryGroups/InventoryGroupItem.jsx | 77 ++++++ .../InventoryGroupItem.test.jsx | 52 ++++ .../InventoryGroups/InventoryGroups.jsx | 261 +++++++++++++++++- .../InventoryGroups/InventoryGroups.test.jsx | 207 ++++++++++++++ .../InventoryHosts/InventoryHostItem.test.jsx | 2 +- .../shared/InventoryGroupsDeleteModal.jsx | 144 ++++++++++ awx/ui_next/src/types.js | 20 ++ 11 files changed, 789 insertions(+), 7 deletions(-) create mode 100644 awx/ui_next/src/api/models/Groups.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index fe49a8247b..f9acea4211 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; +import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; import Inventories from './models/Inventories'; @@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); const CredentialsAPI = new Credentials(); const CredentialTypesAPI = new CredentialTypes(); +const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); const InventoriesAPI = new Inventories(); @@ -55,6 +57,7 @@ export { ConfigAPI, CredentialsAPI, CredentialTypesAPI, + GroupsAPI, HostsAPI, InstanceGroupsAPI, InventoriesAPI, diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js new file mode 100644 index 0000000000..019ba0ea94 --- /dev/null +++ b/awx/ui_next/src/api/models/Groups.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Groups extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/groups/'; + } +} + +export default Groups; diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 245d2dbccd..9001376671 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -7,6 +7,10 @@ class Inventories extends InstanceGroupsMixin(Base) { this.baseUrl = '/api/v2/inventories/'; this.readAccessList = this.readAccessList.bind(this); + this.readHosts = this.readHosts.bind(this); + this.readGroups = this.readGroups.bind(this); + this.readGroupsOptions = this.readGroupsOptions.bind(this); + this.promoteGroup = this.promoteGroup.bind(this); } readAccessList(id, params) { @@ -18,6 +22,21 @@ class Inventories extends InstanceGroupsMixin(Base) { readHosts(id, params) { return this.http.get(`${this.baseUrl}${id}/hosts/`, { params }); } + + readGroups(id, params) { + return this.http.get(`${this.baseUrl}${id}/groups/`, { params }); + } + + readGroupsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/groups/`); + } + + promoteGroup(inventoryId, groupId) { + return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, { + id: groupId, + disassociate: true, + }); + } } export default Inventories; diff --git a/awx/ui_next/src/app.scss b/awx/ui_next/src/app.scss index 4498268a13..16ad4a101a 100644 --- a/awx/ui_next/src/app.scss +++ b/awx/ui_next/src/app.scss @@ -110,6 +110,7 @@ --pf-c-modal-box__footer--PaddingRight: 20px; --pf-c-modal-box__footer--PaddingBottom: 20px; --pf-c-modal-box__footer--PaddingLeft: 20px; + --pf-c-modal-box__footer--MarginTop: 24px; justify-content: flex-end; } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx new file mode 100644 index 0000000000..c8f6b78621 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx @@ -0,0 +1,77 @@ +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 { + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +import ActionButtonCell from '@components/ActionButtonCell'; +import DataListCell from '@components/DataListCell'; +import DataListCheck from '@components/DataListCheck'; +import ListActionButton from '@components/ListActionButton'; +import VerticalSeparator from '@components/VerticalSeparator'; + +function InventoryGroupItem({ + i18n, + group, + inventoryId, + isSelected, + onSelect, +}) { + const labelId = `check-action-${group.id}`; + const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`; + const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; + + return ( + + + + + + + {group.name} + + , + + {group.summary_fields.user_capabilities.edit && ( + + + + + + )} + , + ]} + /> + + + ); +} + +InventoryGroupItem.propTypes = { + group: Group.isRequired, + inventoryId: oneOfType([number, string]).isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryGroupItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.jsx new file mode 100644 index 0000000000..fdf275c358 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupItem from './InventoryGroupItem'; + +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('InventoryGroupItem').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/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, +});