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..49f55cd4d8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -1,10 +1,250 @@ -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 handleDelete = async option => { + setIsLoading(true); + + try { + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + /* Delete groups sequentially to avoid api integrity errors */ + for (const group of selected) { + if (option === 'delete') { + await GroupsAPI.destroy(+group.id); + } else if (option === 'promote') { + await InventoriesAPI.promoteGroup(inventoryId, +group.id); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + } catch (error) { + setDeletionError(error); + } + + toggleModal(); + + try { + const { + data: { count, results }, + } = await fetchGroups(inventoryId, location.search); + setGroups(results); + setGroupCount(count); + } catch (error) { + setContentError(error); + } + + 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..2b5a7340c0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -0,0 +1,217 @@ +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 { 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: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/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('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('Radio[id="radio-delete"]').invoke('onChange')(); + }); + wrapper.update(); + 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..ca86d722b5 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; +import { func, bool, arrayOf, object } from 'prop-types'; +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.li` + display: flex; + font-weight: 600; + color: var(--pf-global--danger-color--100); +`; + +const InventoryGroupsDeleteModal = ({ + onClose, + onDelete, + isModalOpen, + groups, + i18n, +}) => { + const [radioOption, setRadioOption] = useState(null); + + 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?` + )} +
+ {groups.map(group => { + return {group.name}; + })} +
+
+ setRadioOption('delete')} + /> + setRadioOption('promote')} + /> +
+
, + document.body + ); +}; + +InventoryGroupsDeleteModal.propTypes = { + onClose: func.isRequired, + onDelete: func.isRequired, + isModalOpen: bool, + groups: arrayOf(object), +}; + +InventoryGroupsDeleteModal.defaultProps = { + isModalOpen: false, + groups: [], +}; + +export default withI18n()(InventoryGroupsDeleteModal); diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index a519b7ab08..5fee265305 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -199,8 +199,6 @@ export const Host = shape({ enabled: bool, instance_id: string, variables: string, - has_active_failures: bool, - has_inventory_sources: bool, last_job: number, last_job_host_summary: number, }); @@ -229,3 +227,17 @@ 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, +});