From 4f257655a9252e764512279490d06226d8cc0cdf Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 15 Mar 2021 14:18:55 -0400 Subject: [PATCH 01/40] allows proper submission of roles --- awx/ui_next/src/components/AddRole/AddResourceRole.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2b4fbccd9d..94db3339ac 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -54,8 +54,9 @@ function AddResourceRole({ onSave, onClose, roles, i18n, resource, onError }) { ); if (selectedIndex > -1) { - selectedRoleRows.splice(selectedIndex, 1); - setSelectedRoleRows(selectedRoleRows); + setSelectedRoleRows( + selectedRoleRows.filter((r, index) => index !== selectedIndex) + ); } else { setSelectedRoleRows([...selectedRoleRows, role]); } From 7e7bb5261b7a54ddb7160bfe2b73081cc9d5c935 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 17 Feb 2021 14:03:51 -0500 Subject: [PATCH 02/40] 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 05/40] 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 06/40] 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({ ) : ( + )} + {!isJobRunning(job.status) && + job?.summary_fields?.user_capabilities?.delete && ( + + {i18n._(t`Delete`)} + + )} + {showCancelModal && isJobRunning(job.status) && ( + setShowCancelModal(false)} + title={i18n._(t`Cancel Job`)} + label={i18n._(t`Cancel Job`)} + actions={[ + , + , + ]} + > + {i18n._( + t`Are you sure you want to submit the request to cancel this job?` + )} + + )} + {dismissableCancelError && ( + + + + )} {errorMsg && ( Date: Tue, 23 Mar 2021 14:12:57 -0400 Subject: [PATCH 08/40] Use the type-specific job models Add the expected launch and run methods to the different job type models. Include a new helper function to look up the right model given a job type and use it in place of switch statements or passing the type in to build a url. --- awx/ui_next/src/api/mixins/Relaunch.mixin.js | 12 --- awx/ui_next/src/api/mixins/Runnable.mixin.js | 48 +++++++++ awx/ui_next/src/api/models/AdHocCommands.js | 8 +- .../src/api/models/InventoryUpdates.js | 8 +- awx/ui_next/src/api/models/Jobs.js | 62 ++---------- awx/ui_next/src/api/models/ProjectUpdates.js | 7 +- awx/ui_next/src/api/models/SystemJobs.js | 8 +- awx/ui_next/src/api/models/WorkflowJobs.js | 8 +- .../src/components/JobList/JobList.jsx | 31 +----- .../src/components/JobList/JobList.test.jsx | 13 ++- .../JobList/JobListCancelButton.jsx | 2 +- awx/ui_next/src/screens/Job/Job.jsx | 77 ++++++++------- .../src/screens/Job/JobDetail/JobDetail.jsx | 20 +--- .../src/screens/Job/JobOutput/JobOutput.jsx | 98 +++++++------------ .../screens/Job/JobOutput/JobOutput.test.jsx | 2 +- .../src/screens/Job/JobTypeRedirect.jsx | 4 +- awx/ui_next/src/screens/Job/Jobs.jsx | 10 +- awx/ui_next/src/screens/Job/useWsJob.js | 6 +- awx/ui_next/src/util/jobs.js | 21 +++- awx/ui_next/src/util/jobs.test.js | 22 ++++- 20 files changed, 230 insertions(+), 237 deletions(-) delete mode 100644 awx/ui_next/src/api/mixins/Relaunch.mixin.js create mode 100644 awx/ui_next/src/api/mixins/Runnable.mixin.js diff --git a/awx/ui_next/src/api/mixins/Relaunch.mixin.js b/awx/ui_next/src/api/mixins/Relaunch.mixin.js deleted file mode 100644 index 06594c6dd3..0000000000 --- a/awx/ui_next/src/api/mixins/Relaunch.mixin.js +++ /dev/null @@ -1,12 +0,0 @@ -const RelaunchMixin = parent => - class extends parent { - relaunch(id, data) { - return this.http.post(`${this.baseUrl}${id}/relaunch/`, data); - } - - readRelaunch(id) { - return this.http.get(`${this.baseUrl}${id}/relaunch/`); - } - }; - -export default RelaunchMixin; diff --git a/awx/ui_next/src/api/mixins/Runnable.mixin.js b/awx/ui_next/src/api/mixins/Runnable.mixin.js new file mode 100644 index 0000000000..ba8aac8681 --- /dev/null +++ b/awx/ui_next/src/api/mixins/Runnable.mixin.js @@ -0,0 +1,48 @@ +const Runnable = parent => + class extends parent { + jobEventSlug = '/events/'; + + cancel(id) { + const endpoint = `${this.baseUrl}${id}/cancel/`; + + return this.http.post(endpoint); + } + + launchUpdate(id, data) { + const endpoint = `${this.baseUrl}${id}/update/`; + + return this.http.post(endpoint, data); + } + + readLaunchUpdate(id) { + const endpoint = `${this.baseUrl}${id}/update/`; + + return this.http.get(endpoint); + } + + readEvents(id, params = {}) { + const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`; + + return this.http.get(endpoint, { params }); + } + + readEventOptions(id) { + const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`; + + return this.http.options(endpoint); + } + + readRelaunch(id) { + const endpoint = `${this.baseUrl}${id}/relaunch/`; + + return this.http.get(endpoint); + } + + relaunch(id, data) { + const endpoint = `${this.baseUrl}${id}/relaunch/`; + + return this.http.post(endpoint, data); + } + }; + +export default Runnable; diff --git a/awx/ui_next/src/api/models/AdHocCommands.js b/awx/ui_next/src/api/models/AdHocCommands.js index 4879b81b32..2db8e7ddf8 100644 --- a/awx/ui_next/src/api/models/AdHocCommands.js +++ b/awx/ui_next/src/api/models/AdHocCommands.js @@ -1,11 +1,15 @@ import Base from '../Base'; -import RelaunchMixin from '../mixins/Relaunch.mixin'; +import RunnableMixin from '../mixins/Runnable.mixin'; -class AdHocCommands extends RelaunchMixin(Base) { +class AdHocCommands extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/ad_hoc_commands/'; } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default AdHocCommands; diff --git a/awx/ui_next/src/api/models/InventoryUpdates.js b/awx/ui_next/src/api/models/InventoryUpdates.js index 1700c7b26b..0d917b0aeb 100644 --- a/awx/ui_next/src/api/models/InventoryUpdates.js +++ b/awx/ui_next/src/api/models/InventoryUpdates.js @@ -1,7 +1,7 @@ import Base from '../Base'; -import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin'; +import RunnableMixin from '../mixins/Runnable.mixin'; -class InventoryUpdates extends LaunchUpdateMixin(Base) { +class InventoryUpdates extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_updates/'; @@ -11,5 +11,9 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) { createSyncCancel(sourceId) { return this.http.post(`${this.baseUrl}${sourceId}/cancel/`); } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default InventoryUpdates; diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index 026ae671f0..ae3b94cc31 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -1,67 +1,23 @@ import Base from '../Base'; -import RelaunchMixin from '../mixins/Relaunch.mixin'; +import RunnableMixin from '../mixins/Runnable.mixin'; -const getBaseURL = type => { - switch (type) { - case 'playbook': - case 'job': - return '/jobs/'; - case 'project': - case 'project_update': - return '/project_updates/'; - case 'management': - case 'management_job': - return '/system_jobs/'; - case 'inventory': - case 'inventory_update': - return '/inventory_updates/'; - case 'command': - case 'ad_hoc_command': - return '/ad_hoc_commands/'; - case 'workflow': - case 'workflow_job': - return '/workflow_jobs/'; - default: - throw new Error('Unable to find matching job type'); - } -}; - -class Jobs extends RelaunchMixin(Base) { +class Jobs extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/jobs/'; + this.jobEventSlug = '/job_events/'; } - cancel(id, type) { - return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`); + cancel(id) { + return this.http.post(`${this.baseUrl}${id}/cancel/`); } - readCredentials(id, type) { - return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`); + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); } - readDetail(id, type) { - return this.http.get(`/api/v2${getBaseURL(type)}${id}/`); - } - - readEvents(id, type = 'playbook', params = {}) { - let endpoint; - if (type === 'playbook') { - endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`; - } else { - endpoint = `/api/v2${getBaseURL(type)}${id}/events/`; - } - return this.http.get(endpoint, { params }); - } - - readEventOptions(id, type = 'playbook') { - let endpoint; - if (type === 'playbook') { - endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`; - } else { - endpoint = `/api/v2${getBaseURL(type)}${id}/events/`; - } - return this.http.options(endpoint); + readDetail(id) { + return this.http.get(`${this.baseUrl}${id}/`); } } diff --git a/awx/ui_next/src/api/models/ProjectUpdates.js b/awx/ui_next/src/api/models/ProjectUpdates.js index 46d0633f0d..3925ae95e9 100644 --- a/awx/ui_next/src/api/models/ProjectUpdates.js +++ b/awx/ui_next/src/api/models/ProjectUpdates.js @@ -1,10 +1,15 @@ import Base from '../Base'; +import RunnableMixin from '../mixins/Runnable.mixin'; -class ProjectUpdates extends Base { +class ProjectUpdates extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/project_updates/'; } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default ProjectUpdates; diff --git a/awx/ui_next/src/api/models/SystemJobs.js b/awx/ui_next/src/api/models/SystemJobs.js index d7b6ec1750..8365f6f65b 100644 --- a/awx/ui_next/src/api/models/SystemJobs.js +++ b/awx/ui_next/src/api/models/SystemJobs.js @@ -1,10 +1,16 @@ import Base from '../Base'; -class SystemJobs extends Base { +import RunnableMixin from '../mixins/Runnable.mixin'; + +class SystemJobs extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/system_jobs/'; } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default SystemJobs; diff --git a/awx/ui_next/src/api/models/WorkflowJobs.js b/awx/ui_next/src/api/models/WorkflowJobs.js index 87e336e8f5..f2799973b0 100644 --- a/awx/ui_next/src/api/models/WorkflowJobs.js +++ b/awx/ui_next/src/api/models/WorkflowJobs.js @@ -1,7 +1,7 @@ import Base from '../Base'; -import RelaunchMixin from '../mixins/Relaunch.mixin'; +import RunnableMixin from '../mixins/Runnable.mixin'; -class WorkflowJobs extends RelaunchMixin(Base) { +class WorkflowJobs extends RunnableMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/workflow_jobs/'; @@ -10,6 +10,10 @@ class WorkflowJobs extends RelaunchMixin(Base) { readNodes(id, params) { return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params }); } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default WorkflowJobs; diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 5c331fd8a5..c92096ad68 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -13,20 +13,12 @@ import useRequest, { useDeleteItems, useDismissableError, } from '../../util/useRequest'; -import isJobRunning from '../../util/jobs'; +import { isJobRunning, getJobModel } from '../../util/jobs'; import { getQSConfig, parseQueryString } from '../../util/qs'; import JobListItem from './JobListItem'; import JobListCancelButton from './JobListCancelButton'; import useWsJobs from './useWsJobs'; -import { - AdHocCommandsAPI, - InventoryUpdatesAPI, - JobsAPI, - ProjectUpdatesAPI, - SystemJobsAPI, - UnifiedJobsAPI, - WorkflowJobsAPI, -} from '../../api'; +import { UnifiedJobsAPI } from '../../api'; function JobList({ i18n, defaultParams, showTypeColumn = false }) { const qsConfig = getQSConfig( @@ -104,7 +96,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { return Promise.all( selected.map(job => { if (isJobRunning(job.status)) { - return JobsAPI.cancel(job.id, job.type); + return getJobModel(job.type).cancel(job.id); } return Promise.resolve(); }) @@ -127,22 +119,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { useCallback(() => { return Promise.all( selected.map(({ type, id }) => { - switch (type) { - case 'job': - return JobsAPI.destroy(id); - case 'ad_hoc_command': - return AdHocCommandsAPI.destroy(id); - case 'system_job': - return SystemJobsAPI.destroy(id); - case 'project_update': - return ProjectUpdatesAPI.destroy(id); - case 'inventory_update': - return InventoryUpdatesAPI.destroy(id); - case 'workflow_job': - return WorkflowJobsAPI.destroy(id); - default: - return null; - } + return getJobModel(type).destroy(id); }) ); }, [selected]), diff --git a/awx/ui_next/src/components/JobList/JobList.test.jsx b/awx/ui_next/src/components/JobList/JobList.test.jsx index 87f74abfeb..45451de8dd 100644 --- a/awx/ui_next/src/components/JobList/JobList.test.jsx +++ b/awx/ui_next/src/components/JobList/JobList.test.jsx @@ -319,13 +319,12 @@ describe('', () => { wrapper.find('JobListCancelButton').invoke('onCancel')(); }); - expect(JobsAPI.cancel).toHaveBeenCalledTimes(6); - expect(JobsAPI.cancel).toHaveBeenCalledWith(1, 'project_update'); - expect(JobsAPI.cancel).toHaveBeenCalledWith(2, 'job'); - expect(JobsAPI.cancel).toHaveBeenCalledWith(3, 'inventory_update'); - expect(JobsAPI.cancel).toHaveBeenCalledWith(4, 'workflow_job'); - expect(JobsAPI.cancel).toHaveBeenCalledWith(5, 'system_job'); - expect(JobsAPI.cancel).toHaveBeenCalledWith(6, 'ad_hoc_command'); + expect(ProjectUpdatesAPI.cancel).toHaveBeenCalledWith(1); + expect(JobsAPI.cancel).toHaveBeenCalledWith(2); + expect(InventoryUpdatesAPI.cancel).toHaveBeenCalledWith(3); + expect(WorkflowJobsAPI.cancel).toHaveBeenCalledWith(4); + expect(SystemJobsAPI.cancel).toHaveBeenCalledWith(5); + expect(AdHocCommandsAPI.cancel).toHaveBeenCalledWith(6); jest.restoreAllMocks(); }); diff --git a/awx/ui_next/src/components/JobList/JobListCancelButton.jsx b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx index 6f008552b7..efad12993b 100644 --- a/awx/ui_next/src/components/JobList/JobListCancelButton.jsx +++ b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx @@ -4,7 +4,7 @@ import { t } from '@lingui/macro'; import { arrayOf, func } from 'prop-types'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { KebabifiedContext } from '../../contexts/Kebabified'; -import isJobRunning from '../../util/jobs'; +import { isJobRunning } from '../../util/jobs'; import AlertModal from '../AlertModal'; import { Job } from '../../types'; diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx index 8c058bbdf6..a46315c397 100644 --- a/awx/ui_next/src/screens/Job/Job.jsx +++ b/awx/ui_next/src/screens/Job/Job.jsx @@ -12,20 +12,32 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; -import { JobsAPI } from '../../api'; import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; import RoutedTabs from '../../components/RoutedTabs'; import useRequest from '../../util/useRequest'; +import { getJobModel } from '../../util/jobs'; import JobDetail from './JobDetail'; import JobOutput from './JobOutput'; import { WorkflowOutput } from './WorkflowOutput'; import useWsJob from './useWsJob'; +// maps the displayed url segments to actual api types +export const JOB_URL_SEGMENT_MAP = { + playbook: 'job', + project: 'project_update', + management: 'system_job', + inventory: 'inventory_update', + command: 'ad_hoc_command', + workflow: 'workflow_job', +}; + function Job({ i18n, setBreadcrumb }) { - const { id, type } = useParams(); + const { id, typeSegment } = useParams(); const match = useRouteMatch(); + const type = JOB_URL_SEGMENT_MAP[typeSegment]; + const { isLoading, error, @@ -34,12 +46,11 @@ function Job({ i18n, setBreadcrumb }) { } = useRequest( useCallback(async () => { let eventOptions = {}; - const { data: jobDetailData } = await JobsAPI.readDetail(id, type); - if (jobDetailData.type !== 'workflow_job') { - const { data: jobEventOptions } = await JobsAPI.readEventOptions( - id, + const { data: jobDetailData } = await getJobModel(type).readDetail(id); + if (type !== 'workflow_job') { + const { data: jobEventOptions } = await getJobModel( type - ); + ).readEventOptions(id); eventOptions = jobEventOptions; } if ( @@ -49,7 +60,7 @@ function Job({ i18n, setBreadcrumb }) { ) { const { data: { results }, - } = await JobsAPI.readCredentials(jobDetailData.id, type); + } = await getJobModel(type).readCredentials(jobDetailData.id); jobDetailData.summary_fields.credentials = results; } @@ -125,37 +136,37 @@ function Job({ i18n, setBreadcrumb }) { - - {job && - job.type === 'workflow_job' && [ - - - , - + + {job && [ + + + , + + {job.type === 'workflow_job' ? ( - , - ]} - {job && - job.type !== 'workflow_job' && [ - - - , - + ) : ( - , - - - - {i18n._(t`View Job Details`)} - - - , - ]} + )} + , + + + + {i18n._(t`View Job Details`)} + + + , + ]} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 3037711e08..c4a128eaf9 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -25,19 +25,11 @@ import { } from '../../../components/LaunchButton'; import StatusIcon from '../../../components/StatusIcon'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; -import isJobRunning from '../../../util/jobs'; +import { getJobModel, isJobRunning } from '../../../util/jobs'; import { toTitleCase } from '../../../util/strings'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { formatDateString } from '../../../util/dates'; import { Job } from '../../../types'; -import { - JobsAPI, - ProjectUpdatesAPI, - SystemJobsAPI, - WorkflowJobsAPI, - InventoriesAPI, - AdHocCommandsAPI, -} from '../../../api'; const VariablesInput = styled(_VariablesInput)` .pf-c-form__label { @@ -60,16 +52,6 @@ const VERBOSITY = { 4: '4 (Connection Debug)', }; -const getJobModel = type => { - if (type === 'ad_hoc_command') return AdHocCommandsAPI; - if (type === 'inventory_update') return InventoriesAPI; - if (type === 'project_update') return ProjectUpdatesAPI; - if (type === 'system_job') return SystemJobsAPI; - if (type === 'workflow_job') return WorkflowJobsAPI; - - return JobsAPI; -}; - function JobDetail({ job, i18n }) { const { created_by, diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 4533dcc585..293986b779 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -37,7 +37,7 @@ import PageControls from './PageControls'; import HostEventModal from './HostEventModal'; import { HostStatusBar, OutputToolbar } from './shared'; import getRowRangePageSize from './shared/jobOutputUtils'; -import isJobRunning from '../../../util/jobs'; +import { getJobModel, isJobRunning } from '../../../util/jobs'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { encodeNonDefaultQueryString, @@ -47,14 +47,6 @@ import { removeParams, getQSConfig, } from '../../../util/qs'; -import { - JobsAPI, - ProjectUpdatesAPI, - SystemJobsAPI, - WorkflowJobsAPI, - InventoriesAPI, - AdHocCommandsAPI, -} from '../../../api'; const QS_CONFIG = getQSConfig('job_output', { order_by: 'start_line', @@ -280,12 +272,7 @@ const cache = new CellMeasurerCache({ defaultHeight: 25, }); -function JobOutput({ - job, - type, - eventRelatedSearchableKeys, - eventSearchableKeys, -}) { +function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { const location = useLocation(); const listRef = useRef(null); const isMounted = useRef(false); @@ -348,8 +335,8 @@ function JobOutput({ request: cancelJob, } = useRequest( useCallback(async () => { - await JobsAPI.cancel(job.id, type); - }, [job.id, type]), + await getJobModel(job.type).cancel(job.id); + }, [job.id, job.type]), {} ); @@ -364,27 +351,10 @@ function JobOutput({ error: deleteError, } = useRequest( useCallback(async () => { - switch (job.type) { - case 'project_update': - await ProjectUpdatesAPI.destroy(job.id); - break; - case 'system_job': - await SystemJobsAPI.destroy(job.id); - break; - case 'workflow_job': - await WorkflowJobsAPI.destroy(job.id); - break; - case 'ad_hoc_command': - await AdHocCommandsAPI.destroy(job.id); - break; - case 'inventory_update': - await InventoriesAPI.destroy(job.id); - break; - default: - await JobsAPI.destroy(job.id); - } + await getJobModel(job.type).destroy(job.id); + history.push('/jobs'); - }, [job, history]) + }, [job.type, job.id, history]) ); const { @@ -417,7 +387,7 @@ function JobOutput({ try { const { data: { results: fetchedEvents = [], count }, - } = await JobsAPI.readEvents(job.id, type, { + } = await getJobModel(job.type).readEvents(job.id, { page: 1, page_size: 50, ...parseQueryString(QS_CONFIG, location.search), @@ -557,31 +527,33 @@ function JobOutput({ ...parseQueryString(QS_CONFIG, location.search), }; - return JobsAPI.readEvents(job.id, type, params).then(response => { - if (isMounted.current) { - const newResults = {}; - let newResultsCssMap = {}; - response.data.results.forEach((jobEvent, index) => { - newResults[firstIndex + index] = jobEvent; - const { lineCssMap } = getLineTextHtml(jobEvent); - newResultsCssMap = { ...newResultsCssMap, ...lineCssMap }; - }); - setResults(prevResults => ({ - ...prevResults, - ...newResults, - })); - setCssMap(prevCssMap => ({ - ...prevCssMap, - ...newResultsCssMap, - })); - setCurrentlyLoading(prevCurrentlyLoading => - prevCurrentlyLoading.filter(n => !loadRange.includes(n)) - ); - loadRange.forEach(n => { - cache.clear(n); - }); - } - }); + return getJobModel(job.type) + .readEvents(job.id, params) + .then(response => { + if (isMounted.current) { + const newResults = {}; + let newResultsCssMap = {}; + response.data.results.forEach((jobEvent, index) => { + newResults[firstIndex + index] = jobEvent; + const { lineCssMap } = getLineTextHtml(jobEvent); + newResultsCssMap = { ...newResultsCssMap, ...lineCssMap }; + }); + setResults(prevResults => ({ + ...prevResults, + ...newResults, + })); + setCssMap(prevCssMap => ({ + ...prevCssMap, + ...newResultsCssMap, + })); + setCurrentlyLoading(prevCurrentlyLoading => + prevCurrentlyLoading.filter(n => !loadRange.includes(n)) + ); + loadRange.forEach(n => { + cache.clear(n); + }); + } + }); }; const scrollToRow = rowIndex => { diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx index ef2fa35190..b7ddc3cf19 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx @@ -268,7 +268,7 @@ describe('', () => { wrapper.find(searchBtn).simulate('click'); }); wrapper.update(); - expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, undefined, { + expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, { order_by: 'start_line', page: 1, page_size: 50, diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx index dbf2256fdc..22b70c7f17 100644 --- a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx +++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx @@ -55,8 +55,8 @@ function JobTypeRedirect({ id, path, view, i18n }) { ); } - const type = JOB_TYPE_URL_SEGMENTS[job.type]; - return ; + const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type]; + return ; } JobTypeRedirect.defaultProps = { diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx index 318729407a..f75d560d70 100644 --- a/awx/ui_next/src/screens/Job/Jobs.jsx +++ b/awx/ui_next/src/screens/Job/Jobs.jsx @@ -21,12 +21,12 @@ function Jobs({ i18n }) { return; } - const type = JOB_TYPE_URL_SEGMENTS[job.type]; + const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type]; setBreadcrumbConfig({ '/jobs': i18n._(t`Jobs`), - [`/jobs/${type}/${job.id}`]: `${job.name}`, - [`/jobs/${type}/${job.id}/output`]: i18n._(t`Output`), - [`/jobs/${type}/${job.id}/details`]: i18n._(t`Details`), + [`/jobs/${typeSegment}/${job.id}`]: `${job.name}`, + [`/jobs/${typeSegment}/${job.id}/output`]: i18n._(t`Output`), + [`/jobs/${typeSegment}/${job.id}/details`]: i18n._(t`Details`), }); }, [i18n] @@ -53,7 +53,7 @@ function Jobs({ i18n }) { - + diff --git a/awx/ui_next/src/screens/Job/useWsJob.js b/awx/ui_next/src/screens/Job/useWsJob.js index ace2cf2ce6..e9461888d7 100644 --- a/awx/ui_next/src/screens/Job/useWsJob.js +++ b/awx/ui_next/src/screens/Job/useWsJob.js @@ -1,10 +1,8 @@ import { useState, useEffect } from 'react'; -import { useParams } from 'react-router-dom'; import useWebsocket from '../../util/useWebsocket'; -import { JobsAPI } from '../../api'; +import { getJobModel } from '../../util/jobs'; export default function useWsJob(initialJob) { - const { type } = useParams(); const [job, setJob] = useState(initialJob); const lastMessage = useWebsocket({ jobs: ['status_changed'], @@ -18,7 +16,7 @@ export default function useWsJob(initialJob) { useEffect( function parseWsMessage() { async function fetchJob() { - const { data } = await JobsAPI.readDetail(job.id, type); + const { data } = await getJobModel(job.type).readDetail(job.id); setJob(data); } diff --git a/awx/ui_next/src/util/jobs.js b/awx/ui_next/src/util/jobs.js index e4129388a5..de227ffc59 100644 --- a/awx/ui_next/src/util/jobs.js +++ b/awx/ui_next/src/util/jobs.js @@ -1,3 +1,22 @@ -export default function isJobRunning(status) { +import { + JobsAPI, + ProjectUpdatesAPI, + SystemJobsAPI, + WorkflowJobsAPI, + InventoryUpdatesAPI, + AdHocCommandsAPI, +} from '../api'; + +export function isJobRunning(status) { return ['new', 'pending', 'waiting', 'running'].includes(status); } + +export function getJobModel(type) { + if (type === 'ad_hoc_command') return AdHocCommandsAPI; + if (type === 'inventory_update') return InventoryUpdatesAPI; + if (type === 'project_update') return ProjectUpdatesAPI; + if (type === 'system_job') return SystemJobsAPI; + if (type === 'workflow_job') return WorkflowJobsAPI; + + return JobsAPI; +} diff --git a/awx/ui_next/src/util/jobs.test.js b/awx/ui_next/src/util/jobs.test.js index 953b06ba17..6dcab23166 100644 --- a/awx/ui_next/src/util/jobs.test.js +++ b/awx/ui_next/src/util/jobs.test.js @@ -1,4 +1,4 @@ -import isJobRunning from './jobs'; +import { getJobModel, isJobRunning } from './jobs'; describe('isJobRunning', () => { test('should return true for new', () => { @@ -23,3 +23,23 @@ describe('isJobRunning', () => { expect(isJobRunning('failed')).toBe(false); }); }); + +describe('getJobModel', () => { + test('should return valid job model in all cases', () => { + const baseUrls = []; + [ + 'ad_hoc_command', + 'inventory_update', + 'project_update', + 'system_job', + 'workflow_job', + 'job', + 'default', + ].forEach(type => { + expect(getJobModel(type)).toHaveProperty('http'); + expect(getJobModel(type).jobEventSlug).toBeDefined(); + baseUrls.push(getJobModel(type).baseUrl); + }); + expect(new Set(baseUrls).size).toBe(baseUrls.length - 1); + }); +}); From 06ce1c2a9252abb52355f7bedefa218b5d663e88 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 18 Mar 2021 09:52:37 -0400 Subject: [PATCH 09/40] adds instance group warnings --- .../PaginatedDataList/ToolbarDeleteButton.jsx | 1 + .../ContainerGroupDetails.jsx | 10 +++++++++- .../InstanceGroupDetails/InstanceGroupDetails.jsx | 10 +++++++++- .../InstanceGroupList/InstanceGroupList.jsx | 12 ++++++++++-- .../InstanceGroupList/InstanceGroupList.test.jsx | 13 ++++++++++++- .../src/util/getRelatedResourceDeleteDetails.js | 15 +++++++++++++++ 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index f35e305be7..a45a0365de 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -258,6 +258,7 @@ function ToolbarDeleteButton({ onClose={() => toggleModal(false)} actions={[