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(true)} - ouiaId={ouiaId} + onClick={() => toggleModal(true)} > {children || i18n._(t`Delete`)} - setIsOpen(false)} - actions={[ - - {i18n._(t`Delete`)} - , - setIsOpen(false)} - > - {i18n._(t`Cancel`)} - , - ]} - > - {i18n._(t`Are you sure you want to delete:`)} - - {name} - + {!deleteMessageError && ( + toggleModal(false)} + actions={[ + + {i18n._(t`Delete`)} + , + toggleModal(false)} + > + {i18n._(t`Cancel`)} + , + ]} + > + {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`)} + + ) : ( toggleModal(true)} + isAriaDisabled={isDisabled} > {i18n._(t`Delete`)} )} - {isModalOpen && ( + + {isModalOpen && !deleteMessageError && ( toggleModal(false)} actions={[ toggleModal(false)} > {i18n._(t`Cancel`)} , @@ -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`] = ` > @@ -92,19 +92,20 @@ exports[` should render button 1`] = ` > ); + const deleteDetailsRequests = deleteRequests.template(selected[0], i18n); + return ( @@ -236,6 +239,11 @@ function TemplateList({ defaultParams, i18n }) { onDelete={handleTemplateDelete} itemsToDelete={selected} pluralizedItemName={i18n._(t`Templates`)} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + '{numItemsToDelete, plural, one {This template is currently being used by some workflow nodes. Are you sure you want to delete it?} other {Deleting these templates could impact some workflow nodes that rely on them. Are you sure you want to delete anyway?}}', + { numItemsToDelete: selected.length } + )} />, ]} /> diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx index 0263b66cb1..18a727b9ec 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -22,6 +22,7 @@ import ErrorDetail from '../../../components/ErrorDetail'; import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; import { Credential } from '../../../types'; import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; const PluginInputMetadata = styled(CodeEditor)` grid-column: 1 / -1; @@ -183,6 +184,11 @@ function CredentialDetail({ i18n, credential }) { fetchDetails(); }, [fetchDetails]); + const deleteDetailsRequests = relatedResourceDeleteRequests.credential( + credential, + i18n + ); + if (hasContentLoading) { return ; } @@ -270,9 +276,14 @@ function CredentialDetail({ i18n, credential }) { {user_capabilities.delete && ( {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx index ced36412f5..4155a2d8a5 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; import { CredentialsAPI } from '../../../api'; +import useSelected from '../../../util/useSelected'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -18,6 +19,7 @@ import PaginatedTable, { import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import CredentialListItem from './CredentialListItem'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; const QS_CONFIG = getQSConfig('credential', { page: 1, @@ -26,9 +28,7 @@ const QS_CONFIG = getQSConfig('credential', { }); function CredentialList({ i18n }) { - const [selected, setSelected] = useState([]); const location = useLocation(); - const { result: { credentials, @@ -77,8 +77,10 @@ function CredentialList({ i18n }) { fetchCredentials(); }, [fetchCredentials]); - const isAllSelected = - selected.length > 0 && selected.length === credentials.length; + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + credentials + ); + const { isLoading: isDeleteLoading, deleteItems: deleteCredentials, @@ -100,21 +102,12 @@ function CredentialList({ i18n }) { setSelected([]); }; - const handleSelectAll = isSelected => { - setSelected(isSelected ? [...credentials] : []); - }; - - 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 canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - + const deleteDetailsRequests = relatedResourceDeleteRequests.credential( + selected[0], + i18n + ); return ( @@ -169,7 +162,9 @@ function CredentialList({ i18n }) { {...props} showSelectAll isAllSelected={isAllSelected} - onSelectAll={handleSelectAll} + onSelectAll={isSelected => + setSelected(isSelected ? [...credentials] : []) + } qsConfig={QS_CONFIG} additionalControls={[ ...(canAdd @@ -180,6 +175,11 @@ function CredentialList({ i18n }) { onDelete={handleDelete} itemsToDelete={selected} pluralizedItemName={i18n._(t`Credentials`)} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + '{numItemsToDelete, plural, one {This credential is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these credentials 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/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx index 5b52c11db0..0d0cbd9643 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx @@ -16,6 +16,7 @@ import { import useRequest, { useDismissableError } from '../../../util/useRequest'; import { CredentialTypesAPI } from '../../../api'; import { jsonToYaml } from '../../../util/yaml'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function CredentialTypeDetails({ credentialType, i18n }) { const { id, name, description, injectors, inputs } = credentialType; @@ -32,6 +33,11 @@ function CredentialTypeDetails({ credentialType, i18n }) { }, [id, history]) ); + const deleteDetailsRequests = relatedResourceDeleteRequests.credentialType( + credentialType, + i18n + ); + const { error, dismissError } = useDismissableError(deleteError); return ( @@ -83,6 +89,10 @@ function CredentialTypeDetails({ credentialType, i18n }) { 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?` + )} > {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index 7c491bd36a..71452c10b6 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.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 CredentialTypeListItem from './CredentialTypeListItem'; const QS_CONFIG = getQSConfig('credential-type', { @@ -106,6 +106,11 @@ function CredentialTypeList({ i18n }) { const canAdd = actions && actions.POST; + const deleteDetailsRequests = relatedResourceDeleteRequests.credentialType( + selected[0], + i18n + ); + return ( <> @@ -162,6 +167,11 @@ function CredentialTypeList({ i18n }) { onDelete={handleDelete} itemsToDelete={selected} 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: selected.length } + )} />, ]} /> diff --git a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx index 32d9eda3e5..f294e5c696 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx @@ -19,6 +19,7 @@ import ChipGroup from '../../../components/ChipGroup'; import { InventoriesAPI } from '../../../api'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { Inventory } from '../../../types'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function InventoryDetail({ inventory, i18n }) { const history = useHistory(); @@ -54,6 +55,11 @@ function InventoryDetail({ inventory, i18n }) { user_capabilities: userCapabilities, } = inventory.summary_fields; + const deleteDetailsRequests = relatedResourceDeleteRequests.inventory( + inventory, + i18n + ); + if (isLoading) { return ; } @@ -126,6 +132,10 @@ function InventoryDetail({ inventory, i18n }) { name={inventory.name} modalTitle={i18n._(t`Delete Inventory`)} onConfirm={deleteInventory} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This inventory 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/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index e152e7cde8..480e9ef7a9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -17,6 +17,7 @@ import { getQSConfig, parseQueryString } from '../../../util/qs'; import useWsInventories from './useWsInventories'; import AddDropDownButton from '../../../components/AddDropDownButton'; import InventoryListItem from './InventoryListItem'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; const QS_CONFIG = getQSConfig('inventory', { page: 1, @@ -126,6 +127,12 @@ function InventoryList({ i18n }) { } } }; + + const deleteDetailsRequests = relatedResourceDeleteRequests.inventory( + selected[0], + i18n + ); + const addInventory = i18n._(t`Add inventory`); const addSmartInventory = i18n._(t`Add smart inventory`); const addButton = ( @@ -216,6 +223,11 @@ function InventoryList({ i18n }) { '{numItemsToDelete, plural, one {The inventory will be in a pending status until the final delete is processed.} other {The inventories will be in a pending status until the final delete is processed.}}', { numItemsToDelete: selected.length } )} + deleteMessage={i18n._( + '{numItemsToDelete, plural, one {This inventory is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these inventories could impact other resources that rely on them. Are you sure you want to delete anyway?}}', + { numItemsToDelete: selected.length } + )} + deleteDetailsRequests={deleteDetailsRequests} />, ]} /> diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx index 4eda04831d..adbd99c7e3 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx @@ -22,6 +22,7 @@ import ErrorDetail from '../../../components/ErrorDetail'; import Popover from '../../../components/Popover'; import useRequest from '../../../util/useRequest'; import { InventorySourcesAPI } from '../../../api'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function InventorySourceDetail({ inventorySource, i18n }) { const { @@ -96,6 +97,11 @@ function InventorySourceDetail({ inventorySource, i18n }) { } }; + const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource( + inventorySource.inventory, + i18n + ); + const VERBOSITY = { 0: i18n._(t`0 (Warning)`), 1: i18n._(t`1 (Info)`), @@ -281,6 +287,10 @@ function InventorySourceDetail({ inventorySource, i18n }) { name={name} modalTitle={i18n._(t`Delete inventory source`)} onConfirm={handleDelete} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This inventory source is currently being used some workflow job template nodes. Are you sure you want to delete it?` + )} > {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx index 4013d9a53b..35221d6b79 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -20,6 +20,7 @@ import AlertModal from '../../../components/AlertModal/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; import InventorySourceListItem from './InventorySourceListItem'; import useWsInventorySources from './useWsInventorySources'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; const QS_CONFIG = getQSConfig('inventory', { not__source: '', @@ -142,6 +143,11 @@ function InventorySourceList({ i18n }) { sourceChoicesOptions && Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST'); const listUrl = `/inventories/${inventoryType}/${id}/sources/`; + + const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource( + id, + i18n + ); return ( <> , ...(canSyncSources ? [ diff --git a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx index db282620a5..21dd1e2f14 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationDetail/OrganizationDetail.jsx @@ -20,6 +20,7 @@ import ErrorDetail from '../../../components/ErrorDetail'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { useConfig } from '../../../contexts/Config'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function OrganizationDetail({ i18n, organization }) { const { @@ -71,6 +72,11 @@ function OrganizationDetail({ i18n, organization }) { const { error, dismissError } = useDismissableError(deleteError); + const deleteDetailsRequests = relatedResourceDeleteRequests.organization( + organization, + i18n + ); + if (hasContentLoading) { return ; } @@ -157,6 +163,10 @@ function OrganizationDetail({ i18n, organization }) { modalTitle={i18n._(t`Delete Organization`)} onConfirm={deleteOrganization} isDisabled={isLoading} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This organization is currently being used some credentials. Are you sure you want to delete it?` + )} > {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index 03b062a83d..d88cff0d6e 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -19,6 +19,7 @@ import PaginatedTable, { } from '../../../components/PaginatedTable'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import OrganizationListItem from './OrganizationListItem'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; const QS_CONFIG = getQSConfig('organization', { page: 1, @@ -116,6 +117,10 @@ function OrganizationsList({ i18n }) { setSelected(selected.concat(row)); } }; + const deleteDetailsRequests = relatedResourceDeleteRequests.organization( + selected[0], + i18n + ); return ( <> @@ -173,6 +178,11 @@ function OrganizationsList({ i18n }) { onDelete={handleOrgDelete} itemsToDelete={selected} 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: selected.length } + )} />, ]} /> diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx index d00ab5da57..f8bcd351b7 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -20,6 +20,7 @@ import CredentialChip from '../../../components/CredentialChip'; import { ProjectsAPI } from '../../../api'; import { toTitleCase } from '../../../util/strings'; import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import ProjectSyncButton from '../shared/ProjectSyncButton'; function ProjectDetail({ project, i18n }) { @@ -52,7 +53,10 @@ function ProjectDetail({ project, i18n }) { ); const { error, dismissError } = useDismissableError(deleteError); - + const deleteDetailsRequests = relatedResourceDeleteRequests.project( + project, + i18n + ); let optionsList = ''; if ( scm_clean || @@ -171,6 +175,10 @@ function ProjectDetail({ project, i18n }) { modalTitle={i18n._(t`Delete Project`)} onConfirm={deleteProject} isDisabled={isLoading} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This project 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/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index acb473a34d..205628ca27 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -18,6 +18,7 @@ import PaginatedTable, { HeaderCell, } from '../../../components/PaginatedTable'; import useWsProjects from './useWsProjects'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import ProjectListItem from './ProjectListItem'; @@ -116,6 +117,11 @@ function ProjectList({ i18n }) { } }; + const deleteDetailsRequests = relatedResourceDeleteRequests.project( + selected[0], + i18n + ); + return ( @@ -194,6 +200,11 @@ function ProjectList({ i18n }) { onDelete={handleProjectDelete} itemsToDelete={selected} pluralizedItemName={i18n._(t`Projects`)} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + '{numItemsToDelete, plural, one {This project is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these projects 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/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx index abd0078571..27276ec543 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx @@ -31,6 +31,7 @@ import { VariablesDetail } from '../../../components/CodeEditor'; import { JobTemplatesAPI } from '../../../api'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; function JobTemplateDetail({ i18n, template }) { const { @@ -96,6 +97,10 @@ function JobTemplateDetail({ i18n, template }) { const { error, dismissError } = useDismissableError(deleteError); + const deleteDetailsRequests = relatedResourceDeleteRequests.template( + template, + i18n + ); const canLaunch = summary_fields.user_capabilities && summary_fields.user_capabilities.start; const verbosityOptions = [ @@ -401,6 +406,10 @@ function JobTemplateDetail({ i18n, template }) { modalTitle={i18n._(t`Delete Job Template`)} onConfirm={deleteJobTemplate} isDisabled={isDeleteLoading} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This job template 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/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx index 7d326d649d..91e5016e89 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx @@ -27,6 +27,7 @@ import ErrorDetail from '../../../components/ErrorDetail'; import { LaunchButton } from '../../../components/LaunchButton'; import Sparkline from '../../../components/Sparkline'; import { toTitleCase } from '../../../util/strings'; +import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import useRequest, { useDismissableError } from '../../../util/useRequest'; function WorkflowJobTemplateDetail({ template, i18n }) { @@ -102,6 +103,11 @@ function WorkflowJobTemplateDetail({ template, i18n }) { type: 'workflow_job', })); + const deleteDetailsRequests = relatedResourceDeleteRequests.template( + template, + i18n + ); + return ( @@ -241,6 +247,10 @@ function WorkflowJobTemplateDetail({ template, i18n }) { modalTitle={i18n._(t`Delete Workflow Job Template`)} onConfirm={deleteWorkflowJobTemplate} isDisabled={isLoading} + deleteDetailsRequests={deleteDetailsRequests} + deleteMessage={i18n._( + t`This workflow job template 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/util/getDeleteDetails.js b/awx/ui_next/src/util/getDeleteDetails.js new file mode 100644 index 0000000000..1f8df31444 --- /dev/null +++ b/awx/ui_next/src/util/getDeleteDetails.js @@ -0,0 +1,128 @@ +import { t } from '@lingui/macro'; + +import { + CredentialsAPI, + InventoriesAPI, + InventorySourcesAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobTemplateNodesAPI, + WorkflowJobTemplatesAPI, +} from '../api'; + +export default async function getDeleteDetails(requests) { + const results = {}; + let error = null; + let hasCount = false; + + try { + await Promise.all( + requests.map(async ({ request, label }) => { + const { + data: { count }, + } = await request(); + if (count > 0) { + results[label] = count; + hasCount = true; + } + }) + ); + } catch (err) { + error = err; + } + + return { + results: hasCount && results, + error, + }; +} + +export const deleteRequests = { + credential: (selected, i18n) => [ + { + request: async () => + JobTemplatesAPI.read({ + credentials: selected.id, + }), + label: i18n._(t`Job Templates`), + }, + { + request: () => ProjectsAPI.read({ credentials: selected.id }), + label: i18n._(t`Projects`), + }, + { + request: () => + InventoriesAPI.read({ + insights_credential: selected.id, + }), + label: i18n._(t`Inventories`), + }, + { + request: () => + InventorySourcesAPI.read({ + credentials__id: selected.id, + }), + label: i18n._(t`Inventory Sources`), + }, + ], + + template: (selected, i18n) => [ + { + request: async () => + WorkflowJobTemplateNodesAPI.read({ + unified_job_template: selected.id, + }), + label: [i18n._(t`Workflow Job Template Node`)], + }, + ], + + credentialType: (selected, i18n) => [ + { + request: async () => + CredentialsAPI.read({ + credential_type__id: selected.id, + }), + label: i18n._(t`Credentials`), + }, + ], + + inventory: (selected, i18n) => [ + { + request: async () => + JobTemplatesAPI.read({ + inventory: selected.id, + }), + label: i18n._(t`Job Templates`), + }, + { + request: () => WorkflowJobTemplatesAPI.read({ inventory: selected.id }), + label: i18n._(t`Workflow Job Template`), + }, + ], + + inventorySource: (inventoryId, i18n) => [ + { + request: async () => { + try { + const { data } = await InventoriesAPI.updateSources(inventoryId); + return WorkflowJobTemplateNodesAPI.read({ + unified_job_template: data[0].inventory_source, + }); + } catch (err) { + throw new Error(err); + } + }, + label: i18n._(t`Workflow Job Template Node`), + }, + ], + + organization: (selected, i18n) => [ + { + request: async () => + CredentialsAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Credential`), + }, + ], +}; diff --git a/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js new file mode 100644 index 0000000000..dc85dc264f --- /dev/null +++ b/awx/ui_next/src/util/getRelatedResourceDeleteDetails.js @@ -0,0 +1,149 @@ +import { t } from '@lingui/macro'; + +import { + CredentialsAPI, + InventoriesAPI, + InventorySourcesAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobTemplateNodesAPI, + WorkflowJobTemplatesAPI, +} from '../api'; + +export async function getRelatedResourceDeleteCounts(requests) { + const results = {}; + let error = null; + let hasCount = false; + + try { + await Promise.all( + requests.map(async ({ request, label }) => { + const { + data: { count }, + } = await request(); + if (count > 0) { + results[label] = count; + hasCount = true; + } + }) + ); + } catch (err) { + error = err; + } + + return { + results: hasCount && results, + error, + }; +} + +export const relatedResourceDeleteRequests = { + credential: (selected, i18n) => [ + { + request: async () => + JobTemplatesAPI.read({ + credentials: selected.id, + }), + label: i18n._(t`Job Templates`), + }, + { + request: () => ProjectsAPI.read({ credentials: selected.id }), + label: i18n._(t`Projects`), + }, + { + request: () => + InventoriesAPI.read({ + insights_credential: selected.id, + }), + label: i18n._(t`Inventories`), + }, + { + request: () => + InventorySourcesAPI.read({ + credentials__id: selected.id, + }), + label: i18n._(t`Inventory Sources`), + }, + ], + + project: (selected, i18n) => [ + { + request: async () => + JobTemplatesAPI.read({ + credentials: selected.id, + }), + label: i18n._(t`Job Templates`), + }, + { + request: () => WorkflowJobTemplatesAPI.read({ credentials: selected.id }), + label: i18n._(t`Workflow Job Templates`), + }, + { + request: () => + InventorySourcesAPI.read({ + credentials__id: selected.id, + }), + label: i18n._(t`Inventory Sources`), + }, + ], + + template: (selected, i18n) => [ + { + request: async () => + WorkflowJobTemplateNodesAPI.read({ + unified_job_template: selected.id, + }), + label: [i18n._(t`Workflow Job Template Node`)], + }, + ], + + credentialType: (selected, i18n) => [ + { + request: async () => + CredentialsAPI.read({ + credential_type__id: selected.id, + }), + label: i18n._(t`Credentials`), + }, + ], + + inventory: (selected, i18n) => [ + { + request: async () => + JobTemplatesAPI.read({ + inventory: selected.id, + }), + label: i18n._(t`Job Templates`), + }, + { + request: () => WorkflowJobTemplatesAPI.read({ inventory: selected.id }), + label: i18n._(t`Workflow Job Template`), + }, + ], + + inventorySource: (inventoryId, i18n) => [ + { + request: async () => { + try { + const { data } = await InventoriesAPI.updateSources(inventoryId); + return WorkflowJobTemplateNodesAPI.read({ + unified_job_template: data[0].inventory_source, + }); + } catch (err) { + throw new Error(err); + } + }, + label: i18n._(t`Workflow Job Template Node`), + }, + ], + + organization: (selected, i18n) => [ + { + request: async () => + CredentialsAPI.read({ + organization: selected.id, + }), + label: i18n._(t`Credential`), + }, + ], +};