diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 1d72507c03..8e48139bc9 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -27,7 +27,7 @@ class Inventories extends Component { }; } - setBreadCrumbConfig = inventory => { + setBreadCrumbConfig = (inventory, group) => { const { i18n } = this.props; if (!inventory) { return; @@ -57,6 +57,13 @@ class Inventories extends Component { ), [`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`), [`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`), + [`/inventories/inventory/${inventory.id}/groups/add`]: i18n._( + t`Create New Group` + ), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/details`]: i18n._(t`Details`), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/edit`]: `${group && group.name}`, }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index 5352b949a4..da49aa6d8e 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -123,7 +123,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { } + render={() => ( + + )} />, { + const loadData = async () => { + try { + const { data } = await GroupsAPI.readDetail(match.params.groupId); + setInventoryGroup(data); + setBreadcrumb(inventory, data); + } catch (err) { + setHasContentError(err); + } finally { + setContentLoading(false); + } + }; + + loadData(); + }, [match.params.groupId, setBreadcrumb, inventory]); + + if (hasContentError) { + return ; + } + if (hasContentLoading) { + return ; + } + return ( + + + {inventoryGroup && [ + { + return ; + }} + />, + { + return ; + }} + />, + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(t`View Inventory Details`)} + + )} + + ) + } + />, + ]} + + ); +} + +export { InventoryGroups as _InventoryGroups }; +export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js new file mode 100644 index 0000000000..9de3820a01 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroup'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx new file mode 100644 index 0000000000..f09510a771 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; + +import ContentError from '@components/ContentError'; +import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; + +function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { + useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]); + const [error, setError] = useState(null); + const handleSubmit = async values => { + values.inventory = inventory.id; + try { + const { data } = await GroupsAPI.create(values); + history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`); + } catch (err) { + setError(err); + } + }; + const handleCancel = () => { + history.push(`/inventories/inventory/${inventory.id}/groups`); + }; + if (error) { + return ; + } + return ( + + ); +} +export default withI18n()(withRouter(InventoryGroupsAdd)); +export { InventoryGroupsAdd as _InventoryGroupsAdd }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx new file mode 100644 index 0000000000..9155a5054a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupAdd from './InventoryGroupAdd'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/1/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} + inventory={{ inventory: { id: 1 } }} + />, + { + context: { + router: { + history, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupEdit renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + await act(async () => { + waitForElement(wrapper, 'isLoading', el => el.length === 0); + }); + expect(history.location.pathname).toEqual('/inventories/1/groups'); + }); + test('handleSubmit should call api', async () => { + await act(async () => { + waitForElement(wrapper, 'isLoading', el => el.length === 0); + }); + await act(async () => { + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + }); + + expect(GroupsAPI.create).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js new file mode 100644 index 0000000000..0e15c69a55 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx new file mode 100644 index 0000000000..5a593ba5a6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { t } from '@lingui/macro'; + +import { CardBody, Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import styled from 'styled-components'; +import { VariablesInput } from '@components/CodeMirrorInput'; +import ContentError from '@components/ContentError'; + +import { GroupsAPI } from '@api'; +import { DetailList, Detail } from '@components/DetailList'; + +const ActionButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 20px; + & > :not(:first-child) { + margin-left: 20px; + } +`; +function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { + const [error, setError] = useState(false); + const handleDelete = async () => { + try { + await GroupsAPI.destroy(inventoryGroup.id); + history.push(`/inventories/inventory/${match.params.id}/groups`); + } catch (err) { + setError(err); + } + }; + + if (error) { + return ; + } + return ( + + + + + + + + + + + + + + + + ); +} +export default withI18n()(withRouter(InventoryGroupDetail)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx new file mode 100644 index 0000000000..d1803d1928 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { MemoryRouter, Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupDetail from './InventoryGroupDetail'; + +jest.mock('@api'); +const inventoryGroup = { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + id: 1, + created: '10:00', + modified: '12:00', + summary_fields: { + created_by: { + username: 'James', + }, + modified_by: { + username: 'Bond', + }, + }, +}; +describe('', () => { + let wrapper; + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ( + + )} + /> + + ); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupDetail renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should call api to delete the group', () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + expect(GroupsAPI.destroy).toBeCalledWith(1); + }); + test('should navigate user to edit form on edit button click', async () => { + wrapper.find('button[aria-label="Edit"]').prop('onClick'); + expect( + wrapper + .find('Router') + .at(1) + .prop('history').location.pathname + ).toEqual('/inventories/inventory/1/groups/1/edit'); + }); + test('details shoudld render with the proper values', () => { + expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( + 'Bar' + ); + expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe( + '10:00 by James' + ); + expect(wrapper.find('Detail[label="Modified"]').prop('value')).toBe( + '12:00 by Bond' + ); + expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js new file mode 100644 index 0000000000..155a1c8e10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx new file mode 100644 index 0000000000..f9e559c6c3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; + +import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; + +function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { + const [error, setError] = useState(null); + + const handleSubmit = async values => { + try { + await GroupsAPI.update(match.params.groupId, values); + } catch (err) { + setError(err); + } finally { + history.push(`/inventories/inventory/${inventory.id}/groups`); + } + }; + const handleCancel = () => { + history.push(`/inventories/inventory/${inventory.id}/groups`); + }; + return ( + + ); +} +export default withI18n()(withRouter(InventoryGroupEdit)); +export { InventoryGroupEdit as _InventoryGroupEdit }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx new file mode 100644 index 0000000000..056d99ac79 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupEdit from './InventoryGroupEdit'; + +jest.mock('@api'); +GroupsAPI.readDetail.mockResolvedValue({ + data: { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + }, +}); +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/1/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { + router: { + history, + route: { + match: { + params: { groupId: 13 }, + }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupEdit renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + expect(history.location.pathname).toEqual('/inventories/1/groups'); + }); + test('handleSubmit should call api', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + expect(GroupsAPI.update).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js new file mode 100644 index 0000000000..75519c821b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx new file mode 100644 index 0000000000..0ae49c22d4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { Formik } from 'formik'; +import { Form, Card, CardBody, CardHeader } from '@patternfly/react-core'; +import { t } from '@lingui/macro'; + +import CardCloseButton from '@components/CardCloseButton'; +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; + +function InventoryGroupForm({ + i18n, + error, + group = {}, + handleSubmit, + handleCancel, + match, +}) { + const initialValues = { + name: group.name || '', + description: group.description || '', + variables: group.variables || '---', + }; + + return ( + + + + + + ( +
+ + + + + + + + + {error ?
error
: null} + + )} + /> +
+
+ ); +} + +export default withI18n()(withRouter(InventoryGroupForm)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx new file mode 100644 index 0000000000..2c3484e1b6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupForm from './InventoryGroupForm'; + +const group = { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'ying: false', +}; +describe('', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should render values for the fields that have them', () => { + expect(wrapper.length).toBe(1); + expect(wrapper.find("FormGroup[label='Name']").length).toBe(1); + expect(wrapper.find("FormGroup[label='Description']").length).toBe(1); + expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js new file mode 100644 index 0000000000..090b2c2f8a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupForm'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx index c8f6b78621..91f0b0c4f9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx @@ -27,7 +27,7 @@ function InventoryGroupItem({ onSelect, }) { const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`; + const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; return ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index 49f55cd4d8..0e3d82a8b0 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -1,250 +1,50 @@ -import React, { useState, useEffect } from 'react'; -import { TrashAltIcon } from '@patternfly/react-icons'; -import { withRouter } from 'react-router-dom'; +import React from 'react'; 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'; -const QS_CONFIG = getQSConfig('host', { - page: 1, - page_size: 20, - order_by: 'name', -}); +import { Switch, Route, withRouter } from 'react-router-dom'; -const DeleteButton = styled(Button)` - padding: 5px 8px; +import InventoryGroupAdd from './InventoryGroupAdd/InventoryGroupAdd'; - &: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; -} - -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; +import InventoryGroup from './InventoryGroup/InventoryGroup'; +import InventoryGroupsList from './InventoryGroupsList'; +function InventoryGroups({ setBreadcrumb, inventory, location, match }) { return ( - <> - ( - row.id === item.id)} - onSelect={() => handleSelect(item)} - /> - )} - renderToolbar={props => ( - -
- - - -
- , - canAdd && ( - - ), - ]} - /> - )} - emptyStateControls={ - canAdd && ( - + {[ + { + return ; + }} + />, + { + return ( + + ); + }} + />, + ( + - ) - } - /> - {deletionError && ( - setDeletionError(null)} - > - {i18n._(t`Failed to delete one or more groups.`)} - - - )} - - + )} + />, + ]} + ); } +export { InventoryGroups as _InventoryGroups }; export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx new file mode 100644 index 0000000000..20f2578afe --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -0,0 +1,248 @@ +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'; + +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; +} + +const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + return { + isModalOpen, + toggleModal, + }; +}; + +function InventoryGroupsList({ 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(InventoryGroupsList));