From 7e7bb5261b7a54ddb7160bfe2b73081cc9d5c935 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 17 Feb 2021 14:03:51 -0500 Subject: [PATCH 1/6] Adds delete warnings on the lists and detail pages that have resources that are used by other resources --- awx/ui_next/src/api/models/Inventories.js | 6 + .../src/components/AlertModal/AlertModal.jsx | 2 +- .../components/DeleteButton/DeleteButton.jsx | 129 +++++++++++---- .../DeleteButton/DeleteButton.test.jsx | 113 +++++++++++++ .../PaginatedDataList/ToolbarDeleteButton.jsx | 126 ++++++++++++--- .../ToolbarDeleteButton.test.jsx | 43 ++++- .../ToolbarDeleteButton.test.jsx.snap | 9 +- .../components/TemplateList/TemplateList.jsx | 8 + .../CredentialDetail/CredentialDetail.jsx | 11 ++ .../CredentialList/CredentialList.jsx | 38 ++--- .../CredentialTypeDetails.jsx | 10 ++ .../CredentialTypeList/CredentialTypeList.jsx | 12 +- .../InventoryDetail/InventoryDetail.jsx | 10 ++ .../Inventory/InventoryList/InventoryList.jsx | 12 ++ .../InventorySourceDetail.jsx | 10 ++ .../InventorySources/InventorySourceList.jsx | 11 ++ .../OrganizationDetail/OrganizationDetail.jsx | 10 ++ .../OrganizationList/OrganizationList.jsx | 10 ++ .../Project/ProjectDetail/ProjectDetail.jsx | 10 +- .../Project/ProjectList/ProjectList.jsx | 11 ++ .../JobTemplateDetail/JobTemplateDetail.jsx | 9 ++ .../WorkflowJobTemplateDetail.jsx | 10 ++ awx/ui_next/src/util/getDeleteDetails.js | 128 +++++++++++++++ .../util/getRelatedResourceDeleteDetails.js | 149 ++++++++++++++++++ 24 files changed, 799 insertions(+), 88 deletions(-) create mode 100644 awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx create mode 100644 awx/ui_next/src/util/getDeleteDetails.js create mode 100644 awx/ui_next/src/util/getRelatedResourceDeleteDetails.js 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/components/AlertModal/AlertModal.jsx b/awx/ui_next/src/components/AlertModal/AlertModal.jsx index 0c443300be..6d238943dd 100644 --- a/awx/ui_next/src/components/AlertModal/AlertModal.jsx +++ b/awx/ui_next/src/components/AlertModal/AlertModal.jsx @@ -77,7 +77,7 @@ function AlertModal({ aria-label={label || i18n._(t`Alert modal`)} aria-labelledby="alert-modal-header-label" isOpen={Boolean(isOpen)} - variant="small" + variant="medium" title={title} {...props} > diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx index 68963bcf12..760e94f62e 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 } 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 DetailsWrapper = styled.span` + :not(:first-of-type) { + padding-left: 10px; + } +`; function DeleteButton({ onConfirm, modalTitle, @@ -14,51 +25,101 @@ function DeleteButton({ children, isDisabled, ouiaId, + deleteMessage, + deleteDetailsRequests, }) { const [isOpen, setIsOpen] = useState(false); + const [deleteMessageError, setDeleteMessageError] = useState(); + const [deleteDetails, setDeleteDetails] = useState({}); + const toggleModal = async isModalOpen => { + if (deleteDetailsRequests?.length && isModalOpen) { + const { results, error } = await getRelatedResourceDeleteCounts( + deleteDetailsRequests + ); + if (error) { + setDeleteMessageError(error); + } else { + setDeleteDetails(results); + } + } + setIsOpen(isModalOpen); + }; + + if (deleteMessageError) { + return ( + { + toggleModal(false); + setDeleteMessageError(); + }} + > + + + ); + } return ( <> - setIsOpen(false)} - actions={[ - , - , - ]} - > - {i18n._(t`Are you sure you want to delete:`)} -
- {name} -
+ {!deleteMessageError && ( + toggleModal(false)} + actions={[ + , + , + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {name} + {Object.values(deleteDetails).length > 0 && ( + +
{deleteMessage}
+
+ {Object.entries(deleteDetails).map(([key, value]) => ( + + {key} {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..2bc6b2f243 --- /dev/null +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx @@ -0,0 +1,113 @@ +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" + /> + ); + }); + + // expect(wrapper.find('Modal')).toHaveLength(0); + 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..8819d8871e 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -10,15 +10,29 @@ 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 DetailsWrapper = styled.span` + :not(:first-of-type) { + padding-left: 10px; + } +`; const requiredField = props => { const { name, username, image } = props; @@ -77,20 +91,40 @@ function ToolbarDeleteButton({ pluralizedItemName, errorMessage, onDelete, + deleteDetailsRequests, warningMessage, + deleteMessage, i18n, cannotDelete, }) { const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); const [isModalOpen, setIsModalOpen] = useState(false); + const [deleteDetails, setDeleteDetails] = useState({}); + const deleteMessages = [warningMessage, deleteMessage].filter( + message => message + ); + + const [deleteMessageError, setDeleteMessageError] = useState(); const handleDelete = () => { onDelete(); toggleModal(); }; - const toggleModal = () => { - setIsModalOpen(!isModalOpen); + const toggleModal = async isOpen => { + if (itemsToDelete.length === 1 && deleteDetailsRequests?.length > 0) { + const { results, error } = await getRelatedResourceDeleteCounts( + deleteDetailsRequests + ); + + if (error) { + setDeleteMessageError(error); + } else { + setDeleteDetails(results); + } + } + + setIsModalOpen(isOpen); }; useEffect(() => { @@ -126,40 +160,55 @@ 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 + if (deleteMessageError) { + return ( + { + toggleModal(false); + setDeleteMessageError(); + }} + > + + + ); + } + return ( <> {isKebabified ? ( - - {i18n._(t`Delete`)} - + + toggleModal(true)} + > + {i18n._(t`Delete`)} + + ) : (
)} - {isModalOpen && ( + + {isModalOpen && !deleteMessageError && ( toggleModal(false)} actions={[ , @@ -186,8 +235,43 @@ function ToolbarDeleteButton({
))} - {warningMessage && ( - + {itemsToDelete.length === 1 && + Object.values(deleteDetails).length > 0 && ( + + {deleteMessages.map(message => ( +
+ {message} +
+ ))} + {itemsToDelete.length === 1 && ( + <> +
+ {Object.entries(deleteDetails).map(([key, value]) => ( + + {key} {value} + + ))} + + )} +
+ } + /> + )} + {itemsToDelete.length > 1 && ( + ( +
{message}
+ ))} + /> )} )} diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx index 487f2c17f0..c06c6217f2 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,6 +26,15 @@ const itemC = { }; describe('', () => { + let deleteDetailsRequests; + beforeEach(() => { + deleteDetailsRequests = [ + { + label: 'job', + request: CredentialsAPI.read.mockResolvedValue({ data: { count: 1 } }), + }, + ]; + }); test('should render button', () => { const wrapper = mountWithContexts( {}} itemsToDelete={[]} /> @@ -27,14 +43,27 @@ describe('', () => { expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot(); }); - test('should open confirmation modal', () => { - const wrapper = mountWithContexts( - {}} itemsToDelete={[itemA]} /> - ); + test('should open confirmation modal', async () => { + let wrapper; + 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(wrapper.find('Modal')).toHaveLength(1); + expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1); }); test('should invoke onDelete prop', () => { 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..bd772719cb 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 @@ -74,7 +74,7 @@ exports[` should render button 1`] = ` > - {!deleteMessageError && ( - toggleModal(false)} - actions={[ - , - , - ]} - > - {i18n._(t`Are you sure you want to delete:`)} -
- {name} - {Object.values(deleteDetails).length > 0 && ( - -
{deleteMessage}
-
- {Object.entries(deleteDetails).map(([key, value]) => ( - - {key} {value} - - ))} - - } - /> - )} -
- )} + + toggleModal(false)} + actions={[ + , + , + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {name} + {Object.values(deleteDetails).length > 0 && ( + +
{deleteMessage}
+
+ {Object.entries(deleteDetails).map(([key, value]) => ( + + {key} {value} + + ))} + + } + /> + )} +
); } diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx index 2bc6b2f243..966fd9b74b 100644 --- a/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx @@ -38,7 +38,6 @@ describe('', () => { ); }); - // expect(wrapper.find('Modal')).toHaveLength(0); await act(async () => { wrapper.find('button').prop('onClick')(); }); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 8819d8871e..2f5d4e4ac0 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -112,7 +112,11 @@ function ToolbarDeleteButton({ }; const toggleModal = async isOpen => { - if (itemsToDelete.length === 1 && deleteDetailsRequests?.length > 0) { + if ( + isOpen && + itemsToDelete.length === 1 && + deleteDetailsRequests?.length > 0 + ) { const { results, error } = await getRelatedResourceDeleteCounts( deleteDetailsRequests ); @@ -195,7 +199,7 @@ function ToolbarDeleteButton({ variant="secondary" aria-label={i18n._(t`Delete`)} onClick={() => toggleModal(true)} - isAriaDisabled={isDisabled} + isDisabled={isDisabled} > {i18n._(t`Delete`)} @@ -203,7 +207,7 @@ function ToolbarDeleteButton({ )} - {isModalOpen && !deleteMessageError && ( + {isModalOpen && ( ', () => { beforeEach(() => { deleteDetailsRequests = [ { - label: 'job', + label: 'Workflow Job Template Node', request: CredentialsAPI.read.mockResolvedValue({ data: { count: 1 } }), }, ]; @@ -62,10 +62,55 @@ describe('', () => { 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('span[aria-label="Workflow Job Template Node: 1"]') + ).toHaveLength(1); 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, + }, + }) + ), + }, + ]; + let wrapper; + 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( 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 bd772719cb..fc24195951 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 @@ -74,7 +74,7 @@ exports[` should render button 1`] = ` > - + {disabledTooltip ? ( + +
+ +
+
+ ) : ( + + )} { + onConfirm(); + toggleModal(false); + }} > {i18n._(t`Delete`)} , @@ -110,9 +127,9 @@ function DeleteButton({
{deleteMessage}

{Object.entries(deleteDetails).map(([key, value]) => ( - - {key} {value} - +
+ {value} +
))} } diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 2f5d4e4ac0..0f74b05fcf 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -28,9 +28,10 @@ import ErrorDetail from '../ErrorDetail'; const WarningMessage = styled(Alert)` margin-top: 10px; `; -const DetailsWrapper = styled.span` - :not(:first-of-type) { - padding-left: 10px; + +const Label = styled.span` + && { + margin-right: 10px; } `; @@ -99,11 +100,7 @@ function ToolbarDeleteButton({ }) { const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); const [isModalOpen, setIsModalOpen] = useState(false); - const [deleteDetails, setDeleteDetails] = useState({}); - - const deleteMessages = [warningMessage, deleteMessage].filter( - message => message - ); + const [deleteDetails, setDeleteDetails] = useState(null); const [deleteMessageError, setDeleteMessageError] = useState(); const handleDelete = () => { @@ -112,6 +109,7 @@ function ToolbarDeleteButton({ }; const toggleModal = async isOpen => { + setDeleteDetails(null); if ( isOpen && itemsToDelete.length === 1 && @@ -164,6 +162,36 @@ function ToolbarDeleteButton({ const isDisabled = itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete); + const buildDeleteWarning = () => { + const deleteMessages = []; + if (warningMessage) { + deleteMessages.push(warningMessage); + } + if (deleteMessage) { + if (itemsToDelete[0]?.type !== 'inventory') { + 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 ( {i18n._(t`Delete`)} @@ -239,42 +270,11 @@ function ToolbarDeleteButton({
))} - {itemsToDelete.length === 1 && - Object.values(deleteDetails).length > 0 && ( - - {deleteMessages.map(message => ( -
- {message} -
- ))} - {itemsToDelete.length === 1 && ( - <> -
- {Object.entries(deleteDetails).map(([key, value]) => ( - - {key} {value} - - ))} - - )} - - } - /> - )} - {itemsToDelete.length > 1 && ( + {(deleteDetails || deleteMessage || warningMessage) && ( ( -
{message}
- ))} + title={buildDeleteWarning()} /> )}
diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx index 12b25157cc..e4366a16eb 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx @@ -27,6 +27,7 @@ const itemC = { describe('', () => { let deleteDetailsRequests; + let wrapper; beforeEach(() => { deleteDetailsRequests = [ { @@ -35,8 +36,13 @@ describe('', () => { }, ]; }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); test('should render button', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( {}} itemsToDelete={[]} /> ); expect(wrapper.find('button')).toHaveLength(1); @@ -44,7 +50,6 @@ describe('', () => { }); test('should open confirmation modal', async () => { - let wrapper; await act(async () => { wrapper = mountWithContexts( ', () => { expect(CredentialsAPI.read).toBeCalled(); expect(wrapper.find('Modal')).toHaveLength(1); expect( - wrapper.find('span[aria-label="Workflow Job Template Node: 1"]') + 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); }); @@ -88,7 +174,7 @@ describe('', () => { ), }, ]; - let wrapper; + await act(async () => { wrapper = mountWithContexts( ', () => { test('should invoke onDelete prop', () => { const onDelete = jest.fn(); - const wrapper = mountWithContexts( + wrapper = mountWithContexts( ); wrapper.find('button').simulate('click'); @@ -127,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); @@ -142,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/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap index b6a72aee71..a1dd54a0a3 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap @@ -119,7 +119,7 @@ exports[` should render initially 1`] = ` title="Remove Team Access" titleIconVariant={null} titleLabel="" - variant="medium" + variant="small" > should render initially 1`] = ` aria-label="Alert modal" aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" aria-modal="true" - class="pf-c-modal-box pf-m-md" - data-ouia-component-id="OUIA-Generated-Modal-medium-1" + class="pf-c-modal-box pf-m-sm" + data-ouia-component-id="OUIA-Generated-Modal-small-1" data-ouia-component-type="PF4/ModalContent" data-ouia-safe="true" id="pf-modal-part-0" @@ -277,13 +277,13 @@ exports[` should render initially 1`] = ` isOpen={true} labelId="pf-modal-part-1" onClose={[Function]} - ouiaId="OUIA-Generated-Modal-medium-1" + ouiaId="OUIA-Generated-Modal-small-1" ouiaSafe={true} showClose={true} title="Remove Team Access" titleIconVariant={null} titleLabel="" - variant="medium" + variant="small" >
should render initially 1`] = ` aria-label="Alert modal" aria-labelledby="pf-modal-part-0 alert-modal-header-label pf-modal-part-1" className="" - data-ouia-component-id="OUIA-Generated-Modal-medium-1" + data-ouia-component-id="OUIA-Generated-Modal-small-1" data-ouia-component-type="PF4/ModalContent" data-ouia-safe={true} id="pf-modal-part-0" style={Object {}} - variant="medium" + variant="small" >
', () => { test('should have proper number of delete detail requests', () => { expect( wrapper.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(4); + ).toHaveLength(6); }); test('should render details', () => { diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx index 6883a9ac26..e07ab770c5 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx @@ -43,7 +43,7 @@ describe('', () => { test('should have proper number of delete detail requests', () => { expect( wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(4); + ).toHaveLength(6); }); test('should fetch credentials from api and render the in the list', () => { diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx index 0d0cbd9643..087757d6aa 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link, useHistory } from 'react-router-dom'; @@ -16,7 +16,11 @@ import { import useRequest, { useDismissableError } from '../../../util/useRequest'; import { CredentialTypesAPI } from '../../../api'; import { jsonToYaml } from '../../../util/yaml'; -import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; +import { + relatedResourceDeleteRequests, + getRelatedResourceDeleteCounts, +} from '../../../util/getRelatedResourceDeleteDetails'; +import ErrorDetail from '../../../components/ErrorDetail'; function CredentialTypeDetails({ credentialType, i18n }) { const { id, name, description, injectors, inputs } = credentialType; @@ -33,12 +37,35 @@ function CredentialTypeDetails({ credentialType, i18n }) { }, [id, history]) ); - const deleteDetailsRequests = relatedResourceDeleteRequests.credentialType( - credentialType, - i18n + const { + result: { isDeleteDisabled }, + error: deleteDetailsError, + request: fetchDeleteDetails, + } = useRequest( + useCallback(async () => { + const { + results: deleteDetails, + error, + } = await getRelatedResourceDeleteCounts( + relatedResourceDeleteRequests.credentialType(credentialType, i18n) + ); + if (error) { + throw new Error(error); + } + if (deleteDetails) { + return { isDeleteDisabled: true }; + } + return { isDeleteDisabled: false }; + }, [credentialType, i18n]), + { isDeleteDisabled: false } ); - const { error, dismissError } = useDismissableError(deleteError); + useEffect(() => { + fetchDeleteDetails(); + }, [fetchDeleteDetails]); + const { error, dismissError } = useDismissableError( + deleteError || deleteDetailsError + ); return ( @@ -88,11 +115,13 @@ function CredentialTypeDetails({ credentialType, i18n }) { name={name} modalTitle={i18n._(t`Delete credential type`)} onConfirm={deleteCredentialType} - isDisabled={isLoading} - deleteDetailsRequests={deleteDetailsRequests} - deleteMessage={i18n._( - t`This credential type is currently being used by some credentials. Are you sure you want to delete it?` - )} + isDisabled={isLoading || isDeleteDisabled} + disabledTooltip={ + isDeleteDisabled && + i18n._( + t`This credential type is currently being used by some credentials and cannot be deleted` + ) + } > {i18n._(t`Delete`)} @@ -105,7 +134,9 @@ function CredentialTypeDetails({ credentialType, i18n }) { onClose={dismissError} title={i18n._(t`Error`)} variant="error" - /> + > + + )} ); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx index 4ba649dd99..45d7a4feb8 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx @@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import { CredentialTypesAPI } from '../../../api'; +import { CredentialTypesAPI, CredentialsAPI } from '../../../api'; import { jsonToYaml } from '../../../util/yaml'; import CredentialTypeDetails from './CredentialTypeDetails'; @@ -66,6 +66,10 @@ function expectDetailToMatch(wrapper, label, value) { describe('', () => { let wrapper; + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); test('should render details properly', async () => { await act(async () => { wrapper = mountWithContexts( @@ -92,10 +96,36 @@ describe('', () => { ); }); - test('should have proper number of delete detail requests', () => { - expect( - wrapper.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(1); + test('should disabled delete and show proper tooltip requests', async () => { + CredentialsAPI.read.mockResolvedValue({ data: { count: 15 } }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect(wrapper.find('DeleteButton').prop('disabledTooltip')).toBe( + 'This credential type is currently being used by some credentials and cannot be deleted' + ); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + expect(wrapper.find('Tooltip').length).toBe(1); + expect(wrapper.find('Tooltip').prop('content')).toBe( + 'This credential type is currently being used by some credentials and cannot be deleted' + ); + }); + + test('should throw error', async () => { + CredentialsAPI.read.mockRejectedValue(new Error('error')); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); }); test('expected api call is made for delete', async () => { diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index 71452c10b6..e702ee8768 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -169,7 +169,7 @@ function CredentialTypeList({ i18n }) { pluralizedItemName={i18n._(t`Credential Types`)} deleteDetailsRequests={deleteDetailsRequests} deleteMessage={i18n._( - '{numItemsToDelete, plural, one {This credential type is currently being used by some credentials. Are you sure you want to delete it?} other {Deleting these credential types could impact other credentials that rely on them. Are you sure you want to delete anyway?}}', + '{numItemsToDelete, plural, one {This credential type is currently being used by some credentials and cannot be deleted.} other {Credential types that are being used by credentials cannot be deleted. Are you sure you want to delete anyway?}}', { numItemsToDelete: selected.length } )} />, diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx index 5357b00ca9..a5c89f8d89 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.jsx @@ -15,6 +15,7 @@ import { import useRequest, { useDismissableError } from '../../../util/useRequest'; import { toTitleCase } from '../../../util/strings'; import { ExecutionEnvironmentsAPI } from '../../../api'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) { const history = useHistory(); @@ -41,7 +42,10 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) { ); const { error, dismissError } = useDismissableError(deleteError); - + const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment( + executionEnvironment, + i18n + ); return ( @@ -120,6 +124,10 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) { onConfirm={deleteExecutionEnvironment} isDisabled={isLoading} ouiaId="delete-button" + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This execution environment is currently being used by other resources. Are you sure you want to delete it?` + )} > {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.test.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.test.jsx index abfb08e094..90c57e1cc8 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.test.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentDetails/ExecutionEnvironmentDetails.test.jsx @@ -175,4 +175,22 @@ describe('', () => { expect(wrapper.find('Button[aria-label="Delete"]')).toHaveLength(0); }); + test('should have proper number of delete detail requests', async () => { + const history = createMemoryHistory({ + initialEntries: ['/execution_environments/42/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + expect( + wrapper.find('DeleteButton').prop('deleteDetailsRequests') + ).toHaveLength(2); + }); }); diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx index 1490ff49e3..3a4fa01824 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx @@ -9,7 +9,7 @@ import { import { ExecutionEnvironmentsAPI } from '../../../api'; import ExecutionEnvironmentList from './ExecutionEnvironmentList'; -jest.mock('../../../api/models/ExecutionEnvironments'); +jest.mock('../../../api'); const executionEnvironments = { data: { @@ -143,7 +143,6 @@ describe('', () => { wrapper.find('Button[aria-label="Delete"]').prop('onClick')() ); wrapper.update(); - await act(async () => wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() ); @@ -185,4 +184,17 @@ describe('', () => { waitForElement(wrapper, 'ExecutionEnvironmentList', el => el.length > 0); expect(wrapper.find('ToolbarAddButton').length).toBe(0); }); + + test('should have proper number of delete detail requests', async () => { + ExecutionEnvironmentsAPI.read.mockResolvedValue(executionEnvironments); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + expect( + wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') + ).toHaveLength(2); + }); }); diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx index 914b884f1f..40c4e5d38d 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnvironmentList.jsx @@ -19,7 +19,7 @@ import PaginatedTable, { import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; - +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import ExecutionEnvironmentsListItem from './ExecutionEnvironmentListItem'; const QS_CONFIG = getQSConfig('execution_environments', { @@ -105,7 +105,10 @@ function ExecutionEnvironmentList({ i18n }) { }; const canAdd = actions && actions.POST; - + const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment( + selected[0], + i18n + ); return ( <> @@ -181,6 +184,11 @@ function ExecutionEnvironmentList({ i18n }) { onDelete={handleDelete} itemsToDelete={selected} pluralizedItemName={i18n._(t`Execution Environments`)} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + '{numItemsToDelete, plural, one {This execution environment is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these execution environemnts could impact other resources that rely on them. Are you sure you want to delete anyway?}}', + { numItemsToDelete: selected.length } + )} />, ]} /> diff --git a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx index 21dd1e2f14..9369eaebfb 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx @@ -165,7 +165,7 @@ function OrganizationDetail({ i18n, organization }) { isDisabled={isLoading} deleteDetailsRequests={deleteDetailsRequests} deleteMessage={i18n._( - t`This organization is currently being used some credentials. Are you sure you want to delete it?` + t`This organization is currently being by other resources. Are you sure you want to delete it?` )} > {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx index 2b5f60a44b..62ea8d3f74 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx @@ -77,7 +77,7 @@ describe('', () => { expect( component.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(1); + ).toHaveLength(4); }); test('should render the expected instance group', async () => { diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index d88cff0d6e..d5dce4dcac 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -180,7 +180,7 @@ function OrganizationsList({ i18n }) { pluralizedItemName={i18n._(t`Organizations`)} deleteDetailsRequests={deleteDetailsRequests} deleteMessage={i18n._( - '{numItemsToDelete, plural, one {This organization is currently being used some credentials. Are you sure you want to delete it?} other {Deleting these organizations could impact some credentials that rely on them. Are you sure you want to delete anyway?}}', + '{numItemsToDelete, plural, one {This organization is currently being by other resources. Are you sure you want to delete it?} other {Deleting these organizations could impact other resources that rely on them. Are you sure you want to delete anyway?}}', { numItemsToDelete: selected.length } )} />, diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx index 7655bfdd1b..6e3d2003cc 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx @@ -102,7 +102,7 @@ describe('', () => { ); expect( wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(1); + ).toHaveLength(4); }); test('Items are rendered after loading', async () => { diff --git a/awx/ui_next/src/util/getRelatedResouceDeleteDetails.test.js b/awx/ui_next/src/util/getRelatedResouceDeleteDetails.test.js index 27ceccfbb2..32f795704a 100644 --- a/awx/ui_next/src/util/getRelatedResouceDeleteDetails.test.js +++ b/awx/ui_next/src/util/getRelatedResouceDeleteDetails.test.js @@ -54,13 +54,13 @@ describe('delete details', () => { getRelatedResourceDeleteCounts( relatedResourceDeleteRequests.project({ id: 1 }, i18n) ); - expect(WorkflowJobTemplatesAPI.read).toBeCalledWith({ - credentials: 1, + expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({ + unified_job_template: 1, }); expect(InventorySourcesAPI.read).toBeCalledWith({ - credentials__id: 1, + source_project: 1, }); - expect(JobTemplatesAPI.read).toBeCalledWith({ credentials: 1 }); + expect(JobTemplatesAPI.read).toBeCalledWith({ project: 1 }); }); test('should call api for templates list', () => { diff --git a/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js index 1a3a4bbbb7..76af4be6ee 100644 --- a/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js +++ b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js @@ -1,6 +1,7 @@ import { t } from '@lingui/macro'; import { + UnifiedJobTemplatesAPI, CredentialsAPI, InventoriesAPI, InventorySourcesAPI, @@ -8,6 +9,12 @@ import { ProjectsAPI, WorkflowJobTemplateNodesAPI, WorkflowJobTemplatesAPI, + CredentialInputSourcesAPI, + TeamsAPI, + NotificationTemplatesAPI, + ExecutionEnvironmentsAPI, + ApplicationsAPI, + OrganizationsAPI, } from '../api'; export async function getRelatedResourceDeleteCounts(requests) { @@ -65,6 +72,20 @@ export const relatedResourceDeleteRequests = { }), label: i18n._(t`Inventory Sources`), }, + { + request: () => + CredentialInputSourcesAPI.read({ + source_credential: selected.id, + }), + label: i18n._(t`Credential`), + }, + { + request: () => + ExecutionEnvironmentsAPI.read({ + credential: selected.id, + }), + label: i18n._(t`Execution Environments`), + }, ], credentialType: (selected, i18n) => [ @@ -111,18 +132,21 @@ export const relatedResourceDeleteRequests = { { request: () => JobTemplatesAPI.read({ - credentials: selected.id, + project: selected.id, }), label: i18n._(t`Job Templates`), }, { - request: () => WorkflowJobTemplatesAPI.read({ credentials: selected.id }), + request: () => + WorkflowJobTemplateNodesAPI.read({ + unified_job_template: selected.id, + }), label: i18n._(t`Workflow Job Templates`), }, { request: () => InventorySourcesAPI.read({ - credentials__id: selected.id, + source_project: selected.id, }), label: i18n._(t`Inventory Sources`), }, @@ -134,7 +158,7 @@ export const relatedResourceDeleteRequests = { WorkflowJobTemplateNodesAPI.read({ unified_job_template: selected.id, }), - label: [i18n._(t`Workflow Job Template Node`)], + label: [i18n._(t`Workflow Job Template Nodes`)], }, ], @@ -146,5 +170,87 @@ export const relatedResourceDeleteRequests = { }), label: i18n._(t`Credential`), }, + { + request: async () => + TeamsAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Teams`), + }, + { + request: async () => + NotificationTemplatesAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Notification Templates`), + }, + { + request: () => + ExecutionEnvironmentsAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Execution Environments`), + }, + { + request: async () => + ProjectsAPI.read({ + organization: selected.id, + }), + label: [i18n._(t`Projects`)], + }, + { + request: () => + InventoriesAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Inventories`), + }, + { + request: () => + ApplicationsAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Applications`), + }, + ], + executionEnvironment: (selected, i18n) => [ + { + request: async () => + UnifiedJobTemplatesAPI.read({ + execution_environment: selected.id, + }), + label: [i18n._(t`Templates`)], + }, + { + request: async () => + ProjectsAPI.read({ + default_environment: selected.id, + }), + label: [i18n._(t`Projects`)], + }, + { + request: async () => + OrganizationsAPI.read({ + execution_environment: selected.id, + }), + label: [i18n._(t`Organizations`)], + }, + { + request: async () => { + try { + const { data } = await WorkflowJobTemplateNodesAPI.read({ + execution_environment: selected.id, + }); + if ( + data.summary_fields.unified_job_template.unified_job_type === + 'inventory_update' + ) { + await InventorySourcesAPI.read(); + } + } catch {} + }, + + label: [i18n._(t`Organizations`)], + }, ], }; From be148b5fd4e1fb28ba3384b6a3e3b86952e7ed2e Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 16 Mar 2021 16:48:54 -0400 Subject: [PATCH 4/6] Adds more execution environment resource to delete warnings --- .../PaginatedDataList/ToolbarDeleteButton.jsx | 11 +++- .../ExecutionEnvironmentDetails.test.jsx | 2 +- .../ExecutionEnviromentList.test.jsx | 2 +- .../InventorySources/InventorySourceList.jsx | 2 +- .../OrganizationDetail.test.jsx | 2 +- .../OrganizationList.test.jsx | 2 +- .../util/getRelatedResourceDeleteDetails.js | 53 +++++++++++++------ 7 files changed, 52 insertions(+), 22 deletions(-) diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 0f74b05fcf..8275d16cac 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -168,7 +168,10 @@ function ToolbarDeleteButton({ deleteMessages.push(warningMessage); } if (deleteMessage) { - if (itemsToDelete[0]?.type !== 'inventory') { + if ( + itemsToDelete[0]?.type !== 'inventory' && + (itemsToDelete.length > 1 || deleteDetails) + ) { deleteMessages.push(deleteMessage); } else if (deleteDetails || itemsToDelete.length > 1) { deleteMessages.push(deleteMessage); @@ -206,6 +209,10 @@ function ToolbarDeleteButton({ ); } + const shouldShowDeleteWarning = + warningMessage || + (itemsToDelete.length === 1 && deleteDetails) || + (itemsToDelete.length > 1 && deleteMessage); return ( <> @@ -270,7 +277,7 @@ function ToolbarDeleteButton({
))} - {(deleteDetails || deleteMessage || warningMessage) && ( + {shouldShowDeleteWarning && ( ', () => { }); expect( wrapper.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(2); + ).toHaveLength(4); }); }); diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx index 3a4fa01824..d18863e426 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/ExecutionEnvironmentList/ExecutionEnviromentList.test.jsx @@ -195,6 +195,6 @@ describe('', () => { }); expect( wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(2); + ).toHaveLength(4); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx index 35221d6b79..33be5b14b2 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -182,7 +182,7 @@ function InventorySourceList({ i18n }) { pluralizedItemName={i18n._(t`Inventory Sources`)} deleteDetailsRequests={deleteDetailsRequests} deleteMessage={i18n._( - '{numItemsToDelete, plural, one {This inventory source is currently being used workflow job template nodes. Are you sure you want to delete it?} other {Deleting these inventory sources could impact some workflow job template nodes that rely on them. Are you sure you want to delete anyway?}}', + '{numItemsToDelete, plural, one {This inventory source is currently being used by workflow job template nodes. Are you sure you want to delete it?} other {Deleting these inventory sources could impact workflow job template nodes that rely on them. Are you sure you want to delete anyway?}}', { numItemsToDelete: selected.length } )} />, diff --git a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx index 62ea8d3f74..d91b38c85d 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.test.jsx @@ -77,7 +77,7 @@ describe('', () => { expect( component.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(4); + ).toHaveLength(7); }); test('should render the expected instance group', async () => { diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx index 6e3d2003cc..02c6c65b0e 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.test.jsx @@ -102,7 +102,7 @@ describe('', () => { ); expect( wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(4); + ).toHaveLength(7); }); test('Items are rendered after loading', async () => { diff --git a/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js index 76af4be6ee..f218050bd4 100644 --- a/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js +++ b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js @@ -117,14 +117,26 @@ export const relatedResourceDeleteRequests = { request: async () => { try { const { data } = await InventoriesAPI.updateSources(inventoryId); - return WorkflowJobTemplateNodesAPI.read({ - unified_job_template: data[0].inventory_source, - }); + let total = 0; + await Promise.all( + data.map(async datum => { + const { + data: { count }, + } = await WorkflowJobTemplateNodesAPI.read({ + unified_job_template: datum.inventory_source, + }); + if (count > 0) { + total += count; + } + }) + ); + console.log(total, 'total'); + return { data: { count: total } }; } catch (err) { throw new Error(err); } }, - label: i18n._(t`Workflow Job Template Node`), + label: i18n._(t`Workflow Job Template Nodes`), }, ], @@ -231,26 +243,37 @@ export const relatedResourceDeleteRequests = { { request: async () => OrganizationsAPI.read({ - execution_environment: selected.id, + default_environment: selected.id, }), label: [i18n._(t`Organizations`)], }, { request: async () => { try { - const { data } = await WorkflowJobTemplateNodesAPI.read({ + const { + data: { results }, + } = await InventorySourcesAPI.read({ execution_environment: selected.id, }); - if ( - data.summary_fields.unified_job_template.unified_job_type === - 'inventory_update' - ) { - await InventorySourcesAPI.read(); - } - } catch {} + let total = 0; + await Promise.all( + results.map(async result => { + const { + data: { count }, + } = await WorkflowJobTemplateNodesAPI.read({ + unified_job_template: result.id, + }); + if (count > 0) { + total += count; + } + }) + ); + return { data: { count: total } }; + } catch (err) { + throw new Error(err); + } }, - - label: [i18n._(t`Organizations`)], + label: [i18n._(t`Workflow Job Template Nodes`)], }, ], }; From 652e7a500bce2b200f040bdf3e0ee313663cea1c Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 17 Mar 2021 13:25:42 -0400 Subject: [PATCH 5/6] adds counts for inventory groups and hosts that are related to an inventory source --- .../src/api/models/InventorySources.js | 8 +++ .../components/DeleteButton/DeleteButton.jsx | 9 +++ .../PaginatedDataList/ToolbarDeleteButton.jsx | 12 +++- .../ToolbarDeleteButton.test.jsx.snap | 4 +- .../ExecutionEnviromentList.test.jsx | 32 ++++++++++- .../InventorySourceDetail.jsx | 5 +- .../InventorySourceDetail.test.jsx | 2 +- .../InventorySources/InventorySourceList.jsx | 5 +- .../InventorySourceList.test.jsx | 6 +- .../util/getRelatedResourceDeleteDetails.js | 55 +++++++++++-------- 10 files changed, 102 insertions(+), 36 deletions(-) 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/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx index 7d82a3239d..783360ab59 100644 --- a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx @@ -32,7 +32,10 @@ function DeleteButton({ 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 @@ -43,6 +46,7 @@ function DeleteButton({ setDeleteDetails(results); } } + setIsLoading(false); setIsOpen(isModalOpen); }; @@ -66,10 +70,13 @@ function DeleteButton({
@@ -77,6 +84,8 @@ function DeleteButton({ ) : (