diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index c9d774e002..bf049f911b 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -78,6 +78,12 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } + updateSources(inventoryId) { + return this.http.get( + `${this.baseUrl}${inventoryId}/update_inventory_sources/` + ); + } + async readSourceDetail(inventoryId, sourceId) { const { data: { results }, diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index baa2a85cb0..479978db13 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -22,6 +22,14 @@ class InventorySources extends LaunchUpdateMixin( }); } + readGroups(id) { + return this.http.get(`${this.baseUrl}${id}/groups/`); + } + + readHosts(id) { + return this.http.get(`${this.baseUrl}${id}/hosts/`); + } + destroyGroups(id) { return this.http.delete(`${this.baseUrl}${id}/groups/`); } diff --git a/awx/ui_next/src/api/models/Metrics.js b/awx/ui_next/src/api/models/Metrics.js new file mode 100644 index 0000000000..e808d26662 --- /dev/null +++ b/awx/ui_next/src/api/models/Metrics.js @@ -0,0 +1,9 @@ +import Base from '../Base'; + +class Metrics extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/inventories/'; + } +} +export default Metrics; diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx index 68963bcf12..783360ab59 100644 --- a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx @@ -2,9 +2,20 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; +import styled from 'styled-components'; +import { Button, Badge, Alert, Tooltip } from '@patternfly/react-core'; import AlertModal from '../AlertModal'; +import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails'; +import ErrorDetail from '../ErrorDetail'; +const WarningMessage = styled(Alert)` + margin-top: 10px; +`; +const Label = styled.span` + && { + margin-right: 10px; + } +`; function DeleteButton({ onConfirm, modalTitle, @@ -14,33 +25,91 @@ function DeleteButton({ children, isDisabled, ouiaId, + deleteMessage, + deleteDetailsRequests, + disabledTooltip, }) { const [isOpen, setIsOpen] = useState(false); + const [deleteMessageError, setDeleteMessageError] = useState(); + const [deleteDetails, setDeleteDetails] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const toggleModal = async isModalOpen => { + setIsLoading(true); + if (deleteDetailsRequests?.length && isModalOpen) { + const { results, error } = await getRelatedResourceDeleteCounts( + deleteDetailsRequests + ); + if (error) { + setDeleteMessageError(error); + } else { + setDeleteDetails(results); + } + } + setIsLoading(false); + setIsOpen(isModalOpen); + }; + + if (deleteMessageError) { + return ( + { + toggleModal(false); + setDeleteMessageError(); + }} + > + + + ); + } return ( <> - + {disabledTooltip ? ( + +
+ +
+
+ ) : ( + + )} setIsOpen(false)} + onClose={() => toggleModal(false)} actions={[ , @@ -49,7 +118,7 @@ function DeleteButton({ key="cancel" variant="link" aria-label={i18n._(t`Cancel`)} - onClick={() => setIsOpen(false)} + onClick={() => toggleModal(false)} > {i18n._(t`Cancel`)} , @@ -58,6 +127,23 @@ function DeleteButton({ {i18n._(t`Are you sure you want to delete:`)}
{name} + {Object.values(deleteDetails).length > 0 && ( + +
{deleteMessage}
+
+ {Object.entries(deleteDetails).map(([key, value]) => ( +
+ {value} +
+ ))} + + } + /> + )}
); diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx new file mode 100644 index 0000000000..966fd9b74b --- /dev/null +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { CredentialsAPI } from '../../api'; +import DeleteButton from './DeleteButton'; + +jest.mock('../../api'); + +describe('', () => { + test('should render button', () => { + const wrapper = mountWithContexts( + {}} name="Foo" /> + ); + expect(wrapper.find('button')).toHaveLength(1); + }); + + test('should open confirmation modal', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + {}} + name="Foo" + deleteDetailsRequests={[ + { + label: 'job', + request: CredentialsAPI.read.mockResolvedValue({ + data: { count: 1 }, + }), + }, + ]} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + }); + + await act(async () => { + wrapper.find('button').prop('onClick')(); + }); + + await waitForElement(wrapper, 'Modal', el => el.length > 0); + expect(wrapper.find('Modal')).toHaveLength(1); + + expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1); + }); + + test('should invoke onConfirm prop', async () => { + const onConfirm = jest.fn(); + const wrapper = mountWithContexts( + + ); + await act(async () => wrapper.find('button').simulate('click')); + wrapper.update(); + await act(async () => + wrapper + .find('ModalBoxFooter button[aria-label="Confirm Delete"]') + .simulate('click') + ); + wrapper.update(); + expect(onConfirm).toHaveBeenCalled(); + }); + + test('should show delete details error', async () => { + const onConfirm = jest.fn(); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await act(async () => wrapper.find('button').simulate('click')); + wrapper.update(); + + expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 4874ab9f64..a45a0365de 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -10,16 +10,31 @@ import { checkPropTypes, } from 'prop-types'; import styled from 'styled-components'; -import { Alert, Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { + Alert, + Badge, + Button, + DropdownItem, + Tooltip, +} from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; import { KebabifiedContext } from '../../contexts/Kebabified'; +import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails'; + +import ErrorDetail from '../ErrorDetail'; const WarningMessage = styled(Alert)` margin-top: 10px; `; +const Label = styled.span` + && { + margin-right: 10px; + } +`; + const requiredField = props => { const { name, username, image } = props; if (!name && !username && !image) { @@ -77,20 +92,43 @@ function ToolbarDeleteButton({ pluralizedItemName, errorMessage, onDelete, + deleteDetailsRequests, warningMessage, + deleteMessage, i18n, cannotDelete, }) { const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); const [isModalOpen, setIsModalOpen] = useState(false); + const [deleteDetails, setDeleteDetails] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [deleteMessageError, setDeleteMessageError] = useState(); const handleDelete = () => { onDelete(); toggleModal(); }; - const toggleModal = () => { - setIsModalOpen(!isModalOpen); + const toggleModal = async isOpen => { + setIsLoading(true); + setDeleteDetails(null); + if ( + isOpen && + itemsToDelete.length === 1 && + deleteDetailsRequests?.length > 0 + ) { + const { results, error } = await getRelatedResourceDeleteCounts( + deleteDetailsRequests + ); + + if (error) { + setDeleteMessageError(error); + } else { + setDeleteDetails(results); + } + } + setIsLoading(false); + setIsModalOpen(isOpen); }; useEffect(() => { @@ -126,27 +164,84 @@ function ToolbarDeleteButton({ const isDisabled = itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete); - // NOTE: Once PF supports tooltips on disabled elements, - // we can delete the extra
around the below. - // See: https://github.com/patternfly/patternfly-react/issues/1894 + const buildDeleteWarning = () => { + const deleteMessages = []; + if (warningMessage) { + deleteMessages.push(warningMessage); + } + if (deleteMessage) { + if ( + itemsToDelete[0]?.type !== 'inventory' && + (itemsToDelete.length > 1 || deleteDetails) + ) { + deleteMessages.push(deleteMessage); + } else if (deleteDetails || itemsToDelete.length > 1) { + deleteMessages.push(deleteMessage); + } + } + return ( +
+ {deleteMessages.map(message => ( +
+ {message} +
+ ))} + {deleteDetails && + Object.entries(deleteDetails).map(([key, value]) => ( +
+ + {value} +
+ ))} +
+ ); + }; + + if (deleteMessageError) { + return ( + { + toggleModal(false); + setDeleteMessageError(); + }} + > + + + ); + } + const shouldShowDeleteWarning = + warningMessage || + (itemsToDelete.length === 1 && deleteDetails) || + (itemsToDelete.length > 1 && deleteMessage); + return ( <> {isKebabified ? ( - - {i18n._(t`Delete`)} - + + { + toggleModal(true); + }} + > + {i18n._(t`Delete`)} + + ) : (
)} + {isModalOpen && ( toggleModal(false)} actions={[ , @@ -186,8 +286,12 @@ function ToolbarDeleteButton({
))} - {warningMessage && ( - + {shouldShowDeleteWarning && ( + )}
)} diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx index 487f2c17f0..e4366a16eb 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx @@ -1,7 +1,14 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { CredentialsAPI } from '../../api'; import ToolbarDeleteButton from './ToolbarDeleteButton'; +jest.mock('../../api'); + const itemA = { id: 1, name: 'Foo', @@ -19,27 +26,180 @@ const itemC = { }; describe('', () => { + let deleteDetailsRequests; + let wrapper; + beforeEach(() => { + deleteDetailsRequests = [ + { + label: 'Workflow Job Template Node', + request: CredentialsAPI.read.mockResolvedValue({ data: { count: 1 } }), + }, + ]; + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); test('should render button', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( {}} itemsToDelete={[]} /> ); expect(wrapper.find('button')).toHaveLength(1); expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot(); }); - test('should open confirmation modal', () => { - const wrapper = mountWithContexts( - {}} itemsToDelete={[itemA]} /> - ); + test('should open confirmation modal', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + itemsToDelete={[itemA]} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + }); + expect(wrapper.find('Modal')).toHaveLength(0); - wrapper.find('button').simulate('click'); - wrapper.update(); + await act(async () => { + wrapper.find('button').prop('onClick')(); + }); + await waitForElement(wrapper, 'Modal', el => el.length > 0); + expect(CredentialsAPI.read).toBeCalled(); expect(wrapper.find('Modal')).toHaveLength(1); + expect( + wrapper.find('div[aria-label="Workflow Job Template Node: 1"]') + ).toHaveLength(1); + expect( + wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled') + ).toBe(false); + expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1); + }); + + test('should open confirmation with enabled delete button modal', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + itemsToDelete={[ + { + name: 'foo', + id: 1, + type: 'credential_type', + summary_fields: { user_capabilities: { delete: true } }, + }, + { + name: 'bar', + id: 2, + type: 'credential_type', + summary_fields: { user_capabilities: { delete: true } }, + }, + ]} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + }); + + expect(wrapper.find('Modal')).toHaveLength(0); + await act(async () => { + wrapper.find('button').prop('onClick')(); + }); + await waitForElement(wrapper, 'Modal', el => el.length > 0); + expect(CredentialsAPI.read).not.toBeCalled(); + expect(wrapper.find('Modal')).toHaveLength(1); + expect( + wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled') + ).toBe(false); + }); + + test('should disable confirm delete button', async () => { + const request = [ + { + label: 'Workflow Job Template Node', + request: CredentialsAPI.read.mockResolvedValue({ data: { count: 3 } }), + }, + ]; + await act(async () => { + wrapper = mountWithContexts( + {}} + itemsToDelete={[ + { + name: 'foo', + id: 1, + type: 'credential_type', + summary_fields: { user_capabilities: { delete: true } }, + }, + ]} + deleteDetailsRequests={request} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + }); + + expect(wrapper.find('Modal')).toHaveLength(0); + await act(async () => { + wrapper.find('button').prop('onClick')(); + }); + await waitForElement(wrapper, 'Modal', el => el.length > 0); + expect(CredentialsAPI.read).toBeCalled(); + expect(wrapper.find('Modal')).toHaveLength(1); + + expect( + wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled') + ).toBe(true); + expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1); + }); + + test('should open delete error modal', async () => { + const request = [ + { + label: 'Workflow Job Template Node', + request: CredentialsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/credentals', + }, + data: 'An error occurred', + status: 403, + }, + }) + ), + }, + ]; + + await act(async () => { + wrapper = mountWithContexts( + {}} + itemsToDelete={[itemA]} + deleteDetailsRequests={request} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + }); + + expect(wrapper.find('Modal')).toHaveLength(0); + await act(async () => wrapper.find('button').simulate('click')); + await waitForElement(wrapper, 'Modal', el => el.length > 0); + expect(CredentialsAPI.read).toBeCalled(); + + wrapper.update(); + + expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1); }); test('should invoke onDelete prop', () => { const onDelete = jest.fn(); - const wrapper = mountWithContexts( + wrapper = mountWithContexts( ); wrapper.find('button').simulate('click'); @@ -53,14 +213,14 @@ describe('', () => { }); test('should disable button when no delete permissions', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( {}} itemsToDelete={[itemB]} /> ); expect(wrapper.find('button[disabled]')).toHaveLength(1); }); test('should render tooltip', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( {}} itemsToDelete={[itemA]} /> ); expect(wrapper.find('Tooltip')).toHaveLength(1); @@ -68,7 +228,7 @@ describe('', () => { }); test('should render tooltip for username', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( {}} itemsToDelete={[itemC]} /> ); expect(wrapper.find('Tooltip')).toHaveLength(1); diff --git a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap index fc24195951..eb410530a8 100644 --- a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap +++ b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap @@ -75,6 +75,7 @@ exports[` should render button 1`] = `