Merge pull request #9345 from AlexSCorey/5546-DeleteWarnings

Adds delete warnings on lists

SUMMARY
This addresses #5546.
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

AWX VERSION
ADDITIONAL INFORMATION

Reviewed-by: Keith Grant <None>
Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
Reviewed-by: Kersom <None>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-03-25 16:52:13 +00:00 committed by GitHub
commit 705881123c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1561 additions and 100 deletions

View File

@ -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 },

View File

@ -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/`);
}

View File

@ -0,0 +1,9 @@
import Base from '../Base';
class Metrics extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventories/';
}
}
export default Metrics;

View File

@ -2,9 +2,20 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import styled from 'styled-components';
import { Button, Badge, Alert, Tooltip } from '@patternfly/react-core';
import AlertModal from '../AlertModal';
import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails';
import ErrorDetail from '../ErrorDetail';
const WarningMessage = styled(Alert)`
margin-top: 10px;
`;
const Label = styled.span`
&& {
margin-right: 10px;
}
`;
function DeleteButton({
onConfirm,
modalTitle,
@ -14,33 +25,91 @@ function DeleteButton({
children,
isDisabled,
ouiaId,
deleteMessage,
deleteDetailsRequests,
disabledTooltip,
}) {
const [isOpen, setIsOpen] = useState(false);
const [deleteMessageError, setDeleteMessageError] = useState();
const [deleteDetails, setDeleteDetails] = useState({});
const [isLoading, setIsLoading] = useState(false);
const toggleModal = async isModalOpen => {
setIsLoading(true);
if (deleteDetailsRequests?.length && isModalOpen) {
const { results, error } = await getRelatedResourceDeleteCounts(
deleteDetailsRequests
);
if (error) {
setDeleteMessageError(error);
} else {
setDeleteDetails(results);
}
}
setIsLoading(false);
setIsOpen(isModalOpen);
};
if (deleteMessageError) {
return (
<AlertModal
isOpen={deleteMessageError}
title={i18n._(t`Error!`)}
onClose={() => {
toggleModal(false);
setDeleteMessageError();
}}
>
<ErrorDetail error={deleteMessageError} />
</AlertModal>
);
}
return (
<>
<Button
variant={variant || 'secondary'}
aria-label={i18n._(t`Delete`)}
isDisabled={isDisabled}
onClick={() => setIsOpen(true)}
ouiaId={ouiaId}
>
{children || i18n._(t`Delete`)}
</Button>
{disabledTooltip ? (
<Tooltip content={disabledTooltip} position="top">
<div>
<Button
isLoading={isLoading}
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
variant={variant || 'secondary'}
aria-label={i18n._(t`Delete`)}
isDisabled={isDisabled}
onClick={() => toggleModal(true)}
ouiaId={ouiaId}
>
{children || i18n._(t`Delete`)}
</Button>
</div>
</Tooltip>
) : (
<Button
isLoading={isLoading}
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
variant={variant || 'secondary'}
aria-label={i18n._(t`Delete`)}
isDisabled={isDisabled}
onClick={() => toggleModal(true)}
>
{children || i18n._(t`Delete`)}
</Button>
)}
<AlertModal
isOpen={isOpen}
title={modalTitle}
variant="danger"
onClose={() => setIsOpen(false)}
onClose={() => toggleModal(false)}
actions={[
<Button
ouiaId="delete-modal-confirm"
key="delete"
variant="danger"
aria-label={i18n._(t`Delete`)}
aria-label={i18n._(t`Confirm Delete`)}
isDisabled={isDisabled}
onClick={onConfirm}
onClick={() => {
onConfirm();
toggleModal(false);
}}
>
{i18n._(t`Delete`)}
</Button>,
@ -49,7 +118,7 @@ function DeleteButton({
key="cancel"
variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={() => setIsOpen(false)}
onClick={() => toggleModal(false)}
>
{i18n._(t`Cancel`)}
</Button>,
@ -58,6 +127,23 @@ function DeleteButton({
{i18n._(t`Are you sure you want to delete:`)}
<br />
<strong>{name}</strong>
{Object.values(deleteDetails).length > 0 && (
<WarningMessage
variant="warning"
isInline
title={
<div>
<div aria-label={deleteMessage}>{deleteMessage}</div>
<br />
{Object.entries(deleteDetails).map(([key, value]) => (
<div aria-label={`${key}: ${value}`} key={key}>
<Label>{key}</Label> <Badge>{value}</Badge>
</div>
))}
</div>
}
/>
)}
</AlertModal>
</>
);

View File

@ -0,0 +1,112 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { CredentialsAPI } from '../../api';
import DeleteButton from './DeleteButton';
jest.mock('../../api');
describe('<DeleteButton />', () => {
test('should render button', () => {
const wrapper = mountWithContexts(
<DeleteButton onConfirm={() => {}} name="Foo" />
);
expect(wrapper.find('button')).toHaveLength(1);
});
test('should open confirmation modal', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<DeleteButton
onConfirm={() => {}}
name="Foo"
deleteDetailsRequests={[
{
label: 'job',
request: CredentialsAPI.read.mockResolvedValue({
data: { count: 1 },
}),
},
]}
deleteMessage="Delete this?"
warningMessage="Are you sure to want to delete this"
/>
);
});
await act(async () => {
wrapper.find('button').prop('onClick')();
});
await waitForElement(wrapper, 'Modal', el => el.length > 0);
expect(wrapper.find('Modal')).toHaveLength(1);
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
});
test('should invoke onConfirm prop', async () => {
const onConfirm = jest.fn();
const wrapper = mountWithContexts(
<DeleteButton
onConfirm={onConfirm}
itemsToDelete="foo"
deleteDetailsRequests={[
{
label: 'job',
request: CredentialsAPI.read.mockResolvedValue({
data: { count: 1 },
}),
},
]}
deleteMessage="Delete this?"
/>
);
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(
<DeleteButton
onConfirm={onConfirm}
itemsToDelete="foo"
deleteDetailsRequests={[
{
label: 'job',
request: CredentialsAPI.read.mockRejectedValue(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/credentals',
},
data: 'An error occurred',
status: 403,
},
})
),
},
]}
/>
);
});
await act(async () => wrapper.find('button').simulate('click'));
wrapper.update();
expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1);
});
});

View File

@ -10,16 +10,31 @@ import {
checkPropTypes,
} from 'prop-types';
import styled from 'styled-components';
import { Alert, Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import {
Alert,
Badge,
Button,
DropdownItem,
Tooltip,
} from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../AlertModal';
import { KebabifiedContext } from '../../contexts/Kebabified';
import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails';
import ErrorDetail from '../ErrorDetail';
const WarningMessage = styled(Alert)`
margin-top: 10px;
`;
const Label = styled.span`
&& {
margin-right: 10px;
}
`;
const requiredField = props => {
const { name, username, image } = props;
if (!name && !username && !image) {
@ -77,20 +92,43 @@ function ToolbarDeleteButton({
pluralizedItemName,
errorMessage,
onDelete,
deleteDetailsRequests,
warningMessage,
deleteMessage,
i18n,
cannotDelete,
}) {
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const [isModalOpen, setIsModalOpen] = useState(false);
const [deleteDetails, setDeleteDetails] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [deleteMessageError, setDeleteMessageError] = useState();
const handleDelete = () => {
onDelete();
toggleModal();
};
const toggleModal = () => {
setIsModalOpen(!isModalOpen);
const toggleModal = async isOpen => {
setIsLoading(true);
setDeleteDetails(null);
if (
isOpen &&
itemsToDelete.length === 1 &&
deleteDetailsRequests?.length > 0
) {
const { results, error } = await getRelatedResourceDeleteCounts(
deleteDetailsRequests
);
if (error) {
setDeleteMessageError(error);
} else {
setDeleteDetails(results);
}
}
setIsLoading(false);
setIsModalOpen(isOpen);
};
useEffect(() => {
@ -126,27 +164,84 @@ function ToolbarDeleteButton({
const isDisabled =
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
// NOTE: Once PF supports tooltips on disabled elements,
// we can delete the extra <div> around the <DeleteButton> below.
// See: https://github.com/patternfly/patternfly-react/issues/1894
const buildDeleteWarning = () => {
const deleteMessages = [];
if (warningMessage) {
deleteMessages.push(warningMessage);
}
if (deleteMessage) {
if (
itemsToDelete[0]?.type !== 'inventory' &&
(itemsToDelete.length > 1 || deleteDetails)
) {
deleteMessages.push(deleteMessage);
} else if (deleteDetails || itemsToDelete.length > 1) {
deleteMessages.push(deleteMessage);
}
}
return (
<div>
{deleteMessages.map(message => (
<div aria-label={message} key={message}>
{message}
</div>
))}
{deleteDetails &&
Object.entries(deleteDetails).map(([key, value]) => (
<div key={key} aria-label={`${key}: ${value}`}>
<Label>{key}</Label>
<Badge>{value}</Badge>
</div>
))}
</div>
);
};
if (deleteMessageError) {
return (
<AlertModal
isOpen={deleteMessageError}
title={i18n._(t`Error!`)}
onClose={() => {
toggleModal(false);
setDeleteMessageError();
}}
>
<ErrorDetail error={deleteMessageError} />
</AlertModal>
);
}
const shouldShowDeleteWarning =
warningMessage ||
(itemsToDelete.length === 1 && deleteDetails) ||
(itemsToDelete.length > 1 && deleteMessage);
return (
<>
{isKebabified ? (
<DropdownItem
key="add"
isDisabled={isDisabled}
component="button"
onClick={toggleModal}
>
{i18n._(t`Delete`)}
</DropdownItem>
<Tooltip content={renderTooltip()} position="top">
<DropdownItem
key="add"
isDisabled={isDisabled}
isLoading={isLoading}
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
component="button"
onClick={() => {
toggleModal(true);
}}
>
{i18n._(t`Delete`)}
</DropdownItem>
</Tooltip>
) : (
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
variant="secondary"
isLoading={isLoading}
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
onClick={() => toggleModal(true)}
isDisabled={isDisabled}
>
{i18n._(t`Delete`)}
@ -154,17 +249,22 @@ function ToolbarDeleteButton({
</div>
</Tooltip>
)}
{isModalOpen && (
<AlertModal
variant="danger"
title={modalTitle}
isOpen={isModalOpen}
onClose={toggleModal}
onClose={() => toggleModal(false)}
actions={[
<Button
ouiaId="delete-modal-confirm"
key="delete"
variant="danger"
aria-label={i18n._(t`confirm delete`)}
isDisabled={Boolean(
deleteDetails && itemsToDelete[0]?.type === 'credential_type'
)}
onClick={handleDelete}
>
{i18n._(t`Delete`)}
@ -173,7 +273,7 @@ function ToolbarDeleteButton({
key="cancel"
variant="link"
aria-label={i18n._(t`cancel delete`)}
onClick={toggleModal}
onClick={() => toggleModal(false)}
>
{i18n._(t`Cancel`)}
</Button>,
@ -186,8 +286,12 @@ function ToolbarDeleteButton({
<br />
</span>
))}
{warningMessage && (
<WarningMessage variant="warning" isInline title={warningMessage} />
{shouldShowDeleteWarning && (
<WarningMessage
variant="warning"
isInline
title={buildDeleteWarning()}
/>
)}
</AlertModal>
)}

View File

@ -1,7 +1,14 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { CredentialsAPI } from '../../api';
import ToolbarDeleteButton from './ToolbarDeleteButton';
jest.mock('../../api');
const itemA = {
id: 1,
name: 'Foo',
@ -19,27 +26,180 @@ const itemC = {
};
describe('<ToolbarDeleteButton />', () => {
let deleteDetailsRequests;
let wrapper;
beforeEach(() => {
deleteDetailsRequests = [
{
label: 'Workflow Job Template Node',
request: CredentialsAPI.read.mockResolvedValue({ data: { count: 1 } }),
},
];
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render button', () => {
const wrapper = mountWithContexts(
wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[]} />
);
expect(wrapper.find('button')).toHaveLength(1);
expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot();
});
test('should open confirmation modal', () => {
const wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
);
test('should open confirmation modal', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ToolbarDeleteButton
onDelete={() => {}}
itemsToDelete={[itemA]}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage="Delete this?"
warningMessage="Are you sure to want to delete this"
/>
);
});
expect(wrapper.find('Modal')).toHaveLength(0);
wrapper.find('button').simulate('click');
wrapper.update();
await act(async () => {
wrapper.find('button').prop('onClick')();
});
await waitForElement(wrapper, 'Modal', el => el.length > 0);
expect(CredentialsAPI.read).toBeCalled();
expect(wrapper.find('Modal')).toHaveLength(1);
expect(
wrapper.find('div[aria-label="Workflow Job Template Node: 1"]')
).toHaveLength(1);
expect(
wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled')
).toBe(false);
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
});
test('should open confirmation with enabled delete button modal', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ToolbarDeleteButton
onDelete={() => {}}
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(
<ToolbarDeleteButton
onDelete={() => {}}
itemsToDelete={[
{
name: 'foo',
id: 1,
type: 'credential_type',
summary_fields: { user_capabilities: { delete: true } },
},
]}
deleteDetailsRequests={request}
deleteMessage="Delete this?"
warningMessage="Are you sure to want to delete this"
/>
);
});
expect(wrapper.find('Modal')).toHaveLength(0);
await act(async () => {
wrapper.find('button').prop('onClick')();
});
await waitForElement(wrapper, 'Modal', el => el.length > 0);
expect(CredentialsAPI.read).toBeCalled();
expect(wrapper.find('Modal')).toHaveLength(1);
expect(
wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled')
).toBe(true);
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
});
test('should open delete error modal', async () => {
const request = [
{
label: 'Workflow Job Template Node',
request: CredentialsAPI.read.mockRejectedValue(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/credentals',
},
data: 'An error occurred',
status: 403,
},
})
),
},
];
await act(async () => {
wrapper = mountWithContexts(
<ToolbarDeleteButton
onDelete={() => {}}
itemsToDelete={[itemA]}
deleteDetailsRequests={request}
deleteMessage="Delete this?"
warningMessage="Are you sure to want to delete this"
/>
);
});
expect(wrapper.find('Modal')).toHaveLength(0);
await act(async () => wrapper.find('button').simulate('click'));
await waitForElement(wrapper, 'Modal', el => el.length > 0);
expect(CredentialsAPI.read).toBeCalled();
wrapper.update();
expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1);
});
test('should invoke onDelete prop', () => {
const onDelete = jest.fn();
const wrapper = mountWithContexts(
wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
);
wrapper.find('button').simulate('click');
@ -53,14 +213,14 @@ describe('<ToolbarDeleteButton />', () => {
});
test('should disable button when no delete permissions', () => {
const wrapper = mountWithContexts(
wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemB]} />
);
expect(wrapper.find('button[disabled]')).toHaveLength(1);
});
test('should render tooltip', () => {
const wrapper = mountWithContexts(
wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
);
expect(wrapper.find('Tooltip')).toHaveLength(1);
@ -68,7 +228,7 @@ describe('<ToolbarDeleteButton />', () => {
});
test('should render tooltip for username', () => {
const wrapper = mountWithContexts(
wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemC]} />
);
expect(wrapper.find('Tooltip')).toHaveLength(1);

View File

@ -75,6 +75,7 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
<Button
aria-label="Delete"
isDisabled={true}
isLoading={false}
onClick={[Function]}
variant="secondary"
>
@ -93,13 +94,14 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
<Button
aria-label="Delete"
isDisabled={true}
isLoading={false}
onClick={[Function]}
variant="secondary"
>
<button
aria-disabled={true}
aria-label="Delete"
className="pf-c-button pf-m-secondary pf-m-disabled"
className="pf-c-button pf-m-secondary pf-m-disabled pf-m-progress"
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
data-ouia-component-type="PF4/Button"
data-ouia-safe={true}

View File

@ -18,6 +18,7 @@ import { getQSConfig, parseQueryString } from '../../util/qs';
import useWsTemplates from '../../util/useWsTemplates';
import AddDropDownButton from '../AddDropDownButton';
import TemplateListItem from './TemplateListItem';
import { relatedResourceDeleteRequests } from '../../util/getRelatedResourceDeleteDetails';
function TemplateList({ defaultParams, i18n }) {
// The type value in const qsConfig below does not have a space between job_template and
@ -168,6 +169,11 @@ function TemplateList({ defaultParams, i18n }) {
<AddDropDownButton key="add" dropdownItems={addDropDownButton} />
);
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
selected[0],
i18n
);
return (
<Fragment>
<Card>
@ -236,6 +242,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 }
)}
/>,
]}
/>

View File

@ -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 <ContentLoading />;
}
@ -270,9 +276,14 @@ function CredentialDetail({ i18n, credential }) {
{user_capabilities.delete && (
<DeleteButton
name={name}
itemToDelete={credential}
modalTitle={i18n._(t`Delete Credential`)}
onConfirm={deleteCredential}
isLoading={isLoading}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={i18n._(
t`This credential is currently being used by other resources. Are you sure you want to delete it?`
)}
>
{i18n._(t`Delete`)}
</DeleteButton>

View File

@ -65,6 +65,12 @@ describe('<CredentialDetail />', () => {
expect(wrapper.find('CredentialDetail').length).toBe(1);
});
test('should have proper number of delete detail requests', () => {
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(6);
});
test('should render details', () => {
expectDetailToMatch(wrapper, 'Name', mockCredential.name);
expectDetailToMatch(wrapper, 'Description', mockCredential.description);

View File

@ -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 (
<PageSection>
<Card>
@ -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 }
)}
/>,
]}
/>

View File

@ -40,6 +40,12 @@ describe('<CredentialList />', () => {
expect(wrapper.find('CredentialList').length).toBe(1);
});
test('should have proper number of delete detail requests', () => {
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(6);
});
test('should fetch credentials from api and render the in the list', () => {
expect(CredentialsAPI.read).toHaveBeenCalled();
expect(wrapper.find('CredentialListItem').length).toBe(5);

View File

@ -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,6 +16,11 @@ import {
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { CredentialTypesAPI } from '../../../api';
import { jsonToYaml } from '../../../util/yaml';
import {
relatedResourceDeleteRequests,
getRelatedResourceDeleteCounts,
} from '../../../util/getRelatedResourceDeleteDetails';
import ErrorDetail from '../../../components/ErrorDetail';
function CredentialTypeDetails({ credentialType, i18n }) {
const { id, name, description, injectors, inputs } = credentialType;
@ -32,7 +37,35 @@ function CredentialTypeDetails({ credentialType, i18n }) {
}, [id, history])
);
const { error, dismissError } = useDismissableError(deleteError);
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 }
);
useEffect(() => {
fetchDeleteDetails();
}, [fetchDeleteDetails]);
const { error, dismissError } = useDismissableError(
deleteError || deleteDetailsError
);
return (
<CardBody>
@ -82,7 +115,13 @@ function CredentialTypeDetails({ credentialType, i18n }) {
name={name}
modalTitle={i18n._(t`Delete credential type`)}
onConfirm={deleteCredentialType}
isDisabled={isLoading}
isDisabled={isLoading || isDeleteDisabled}
disabledTooltip={
isDeleteDisabled &&
i18n._(
t`This credential type is currently being used by some credentials and cannot be deleted`
)
}
>
{i18n._(t`Delete`)}
</DeleteButton>
@ -95,7 +134,9 @@ function CredentialTypeDetails({ credentialType, i18n }) {
onClose={dismissError}
title={i18n._(t`Error`)}
variant="error"
/>
>
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
);

View File

@ -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('<CredentialTypeDetails/>', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render details properly', async () => {
await act(async () => {
wrapper = mountWithContexts(
@ -92,6 +96,38 @@ describe('<CredentialTypeDetails/>', () => {
);
});
test('should disabled delete and show proper tooltip requests', async () => {
CredentialsAPI.read.mockResolvedValue({ data: { count: 15 } });
await act(async () => {
wrapper = mountWithContexts(
<CredentialTypeDetails credentialType={credentialTypeData} />
);
});
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(
<CredentialTypeDetails credentialType={credentialTypeData} />
);
});
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
test('expected api call is made for delete', async () => {
const history = createMemoryHistory({
initialEntries: ['/credential_types/42/details'],

View File

@ -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 (
<>
<PageSection>
@ -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 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 }
)}
/>,
]}
/>

View File

@ -6,10 +6,11 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { CredentialTypesAPI } from '../../../api';
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
import CredentialTypeList from './CredentialTypeList';
jest.mock('../../../api/models/CredentialTypes');
jest.mock('../../../api/models/Credentials');
const credentialTypes = {
data: {
@ -49,6 +50,12 @@ describe('<CredentialTypeList', () => {
await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0);
});
test('should have proper number of delete detail requests', () => {
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(1);
});
test('should have data fetched and render 2 rows', async () => {
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
CredentialTypesAPI.readOptions.mockResolvedValue(options);
@ -65,6 +72,7 @@ describe('<CredentialTypeList', () => {
test('should delete item successfully', async () => {
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
CredentialTypesAPI.readOptions.mockResolvedValue(options);
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
await act(async () => {
wrapper = mountWithContexts(<CredentialTypeList />);

View File

@ -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 (
<CardBody>
<DetailList>
@ -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`)}
</DeleteButton>

View File

@ -175,4 +175,22 @@ describe('<ExecutionEnvironmentDetails/>', () => {
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(
<ExecutionEnvironmentDetails
executionEnvironment={executionEnvironment}
/>,
{
context: { router: { history } },
}
);
});
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(4);
});
});

View File

@ -6,10 +6,22 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { ExecutionEnvironmentsAPI } from '../../../api';
import {
ExecutionEnvironmentsAPI,
InventorySourcesAPI,
WorkflowJobTemplateNodesAPI,
OrganizationsAPI,
ProjectsAPI,
UnifiedJobTemplatesAPI,
} from '../../../api';
import ExecutionEnvironmentList from './ExecutionEnvironmentList';
jest.mock('../../../api/models/ExecutionEnvironments');
jest.mock('../../../api/models/UnifiedJobTemplates');
jest.mock('../../../api/models/Projects');
jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/InventorySources');
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
const executionEnvironments = {
data: {
@ -43,6 +55,16 @@ describe('<ExecutionEnvironmentList/>', () => {
beforeEach(() => {
ExecutionEnvironmentsAPI.read.mockResolvedValue(executionEnvironments);
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue(options);
InventorySourcesAPI.read.mockResolvedValue({
data: { results: [{ id: 10000000 }] },
});
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
OrganizationsAPI.read.mockResolvedValue({ data: { count: 0 } });
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
ProjectsAPI.read.mockResolvedValue({ data: { count: 0 } });
});
afterEach(() => {
@ -144,6 +166,11 @@ describe('<ExecutionEnvironmentList/>', () => {
);
wrapper.update();
await waitForElement(
wrapper,
'Button[aria-label="confirm delete"]',
el => el.length > 0
);
await act(async () =>
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
@ -185,4 +212,17 @@ describe('<ExecutionEnvironmentList/>', () => {
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(<ExecutionEnvironmentList />);
});
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(4);
});
});

View File

@ -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 (
<>
<PageSection>
@ -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 }
)}
/>,
]}
/>

View File

@ -16,6 +16,7 @@ import {
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { jsonToYaml, isJsonString } from '../../../util/yaml';
import { InstanceGroupsAPI } from '../../../api';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
function ContainerGroupDetails({ instanceGroup, i18n }) {
const { id, name } = instanceGroup;
@ -34,7 +35,10 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
);
const { error, dismissError } = useDismissableError(deleteError);
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
instanceGroup,
i18n
);
return (
<CardBody>
<DetailList>
@ -101,6 +105,10 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
modalTitle={i18n._(t`Delete instance group`)}
onConfirm={deleteInstanceGroup}
isDisabled={isLoading}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={i18n._(
t`This container group is currently being by other resources. Are you sure you want to delete it?`
)}
>
{i18n._(t`Delete`)}
</DeleteButton>

View File

@ -16,6 +16,7 @@ import {
} from '../../../components/DetailList';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { InstanceGroupsAPI } from '../../../api';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
@ -38,7 +39,10 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
);
const { error, dismissError } = useDismissableError(deleteError);
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
instanceGroup,
i18n
);
const verifyInstanceGroup = item => {
if (item.is_isolated) {
return (
@ -142,6 +146,10 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
modalTitle={i18n._(t`Delete instance group`)}
onConfirm={deleteInstanceGroup}
isDisabled={isLoading}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={i18n._(
t`This instance group is currently being by other resources. Are you sure you want to delete it?`
)}
>
{i18n._(t`Delete`)}
</DeleteButton>

View File

@ -17,7 +17,7 @@ import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import AddDropDownButton from '../../../components/AddDropDownButton';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
import InstanceGroupListItem from './InstanceGroupListItem';
const QS_CONFIG = getQSConfig('instance-group', {
@ -186,7 +186,10 @@ function InstanceGroupList({ i18n }) {
? `${match.url}/container_group/${item.id}/details`
: `${match.url}/${item.id}/details`;
};
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
selected[0],
i18n
);
return (
<>
<PageSection>
@ -218,6 +221,11 @@ function InstanceGroupList({ i18n }) {
itemsToDelete={modifiedSelected}
pluralizedItemName={i18n._(t`Instance Groups`)}
errorMessage={errorMessageDelete}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={i18n._(
'{numItemsToDelete, plural, one {This instance group is currently being by other resources. Are you sure you want to delete it?} other {Deleting these instance groups could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
{ numItemsToDelete: selected.length }
)}
/>,
]}
/>

View File

@ -6,10 +6,18 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { InstanceGroupsAPI } from '../../../api';
import {
InstanceGroupsAPI,
OrganizationsAPI,
InventoriesAPI,
UnifiedJobTemplatesAPI,
} from '../../../api';
import InstanceGroupList from './InstanceGroupList';
jest.mock('../../../api/models/InstanceGroups');
jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/UnifiedJobTemplates');
const instanceGroups = {
data: {
@ -44,6 +52,9 @@ const instanceGroups = {
};
const options = { data: { actions: { POST: true } } };
OrganizationsAPI.read.mockResolvedValue({ data: { count: 0 } });
InventoriesAPI.read.mockResolvedValue({ data: { count: 0 } });
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
describe('<InstanceGroupList />', () => {
let wrapper;

View File

@ -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 <ContentLoading />;
}
@ -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`)}
</DeleteButton>

View File

@ -105,6 +105,18 @@ describe('<InventoryDetail />', () => {
expect(dates.at(1).prop('date')).toEqual(mockInventory.modified);
});
test('should have proper number of delete detail requests', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<InventoryDetail inventory={mockInventory} />
);
});
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(2);
});
test('should load instance groups', async () => {
InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: {

View File

@ -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}
/>,
]}
/>

View File

@ -1,11 +1,17 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI } from '../../../api';
import {
InventoriesAPI,
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import InventoryList from './InventoryList';
jest.mock('../../../api');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/JobTemplates');
jest.mock('../../../api/models/WorkflowJobTemplates');
const mockInventories = [
{
@ -136,6 +142,8 @@ describe('<InventoryList />', () => {
},
},
});
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
debug = global.console.debug; // eslint-disable-line prefer-destructuring
global.console.debug = () => {};
});
@ -155,6 +163,16 @@ describe('<InventoryList />', () => {
expect(wrapper.find('InventoryListItem')).toHaveLength(3);
});
test('should have proper number of delete detail requests', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<InventoryList />);
});
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(2);
});
test('should select inventory when checked', async () => {
let wrapper;
await act(async () => {

View File

@ -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,12 @@ function InventorySourceDetail({ inventorySource, i18n }) {
}
};
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
inventorySource.inventory,
i18n,
inventorySource
);
const VERBOSITY = {
0: i18n._(t`0 (Warning)`),
1: i18n._(t`1 (Info)`),
@ -281,6 +288,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 by other resources that rely on it. Are you sure you want to delete it?`
)}
>
{i18n._(t`Delete`)}
</DeleteButton>

View File

@ -7,9 +7,20 @@ import {
} from '../../../../testUtils/enzymeHelpers';
import InventorySourceDetail from './InventorySourceDetail';
import mockInvSource from '../shared/data.inventory_source.json';
import { InventorySourcesAPI } from '../../../api';
import {
InventorySourcesAPI,
InventoriesAPI,
WorkflowJobTemplateNodesAPI,
} from '../../../api';
jest.mock('../../../api/models/InventorySources');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
InventoriesAPI.updateSources.mockResolvedValue({
data: [{ inventory_source: 1 }],
});
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
InventorySourcesAPI.readOptions.mockResolvedValue({
data: {
actions: {
@ -101,6 +112,17 @@ describe('InventorySourceDetail', () => {
expect(wrapper.find('InventorySourceSyncButton')).toHaveLength(1);
});
test('should have proper number of delete detail requests', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
});
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(3);
});
test('should hide expected action buttons for users without permissions', async () => {
const userCapabilities = {
edit: false,

View File

@ -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,12 @@ function InventorySourceList({ i18n }) {
sourceChoicesOptions &&
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
const listUrl = `/inventories/${inventoryType}/${id}/sources/`;
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
id,
i18n,
selected[0]
);
return (
<>
<PaginatedDataList
@ -174,6 +181,11 @@ function InventorySourceList({ i18n }) {
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Inventory Sources`)}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={i18n._(
'{numItemsToDelete, plural, one {This inventory source is currently being used by other resources that rely on it. Are you sure you want to delete it?} other {Deleting these inventory sources could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
{ numItemsToDelete: selected.length }
)}
/>,
...(canSyncSources
? [

View File

@ -2,7 +2,11 @@ import React from 'react';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI, InventorySourcesAPI } from '../../../api';
import {
InventoriesAPI,
InventorySourcesAPI,
WorkflowJobTemplateNodesAPI,
} from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -13,6 +17,7 @@ import InventorySourceList from './InventorySourceList';
jest.mock('../../../api/models/InventorySources');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/InventoryUpdates');
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
const sources = {
data: {
@ -61,6 +66,12 @@ describe('<InventorySourceList />', () => {
debug = global.console.debug; // eslint-disable-line prefer-destructuring
global.console.debug = () => {};
InventoriesAPI.readSources.mockResolvedValue(sources);
InventoriesAPI.updateSources.mockResolvedValue({
data: [{ inventory_source: 1 }],
});
InventorySourcesAPI.readGroups.mockResolvedValue({ data: { count: 0 } });
InventorySourcesAPI.readHosts.mockResolvedValue({ data: { count: 0 } });
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
InventorySourcesAPI.readOptions.mockResolvedValue({
data: {
actions: {
@ -119,6 +130,12 @@ describe('<InventorySourceList />', () => {
expect(InventorySourcesAPI.readOptions).toHaveBeenCalled();
});
test('should have proper number of delete detail requests', async () => {
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(3);
});
test('source data should render properly', async () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
expect(

View File

@ -116,7 +116,7 @@ describe('<JobDetail />', () => {
wrapper.update();
const modal = wrapper.find('Modal');
expect(modal.length).toBe(1);
modal.find('button[aria-label="Delete"]').simulate('click');
modal.find('button[aria-label="Confirm Delete"]').simulate('click');
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
});
@ -138,7 +138,7 @@ describe('<JobDetail />', () => {
const modal = wrapper.find('Modal');
expect(modal.length).toBe(1);
await act(async () => {
modal.find('button[aria-label="Delete"]').simulate('click');
modal.find('button[aria-label="Confirm Delete"]').simulate('click');
});
wrapper.update();

View File

@ -188,9 +188,19 @@ describe('<JobOutput />', () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
await act(async () =>
wrapper.find('button[aria-label="Delete"]').simulate('click')
);
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Delete Job'
);
await act(async () =>
wrapper
.find('Modal button[aria-label="Confirm Delete"]')
.simulate('click')
);
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
});

View File

@ -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 <ContentLoading />;
}
@ -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 by other resources. Are you sure you want to delete it?`
)}
>
{i18n._(t`Delete`)}
</DeleteButton>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { OrganizationsAPI } from '../../../api';
import { OrganizationsAPI, CredentialsAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -44,6 +44,8 @@ describe('<OrganizationDetail />', () => {
};
beforeEach(() => {
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
OrganizationsAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
});
@ -64,6 +66,20 @@ describe('<OrganizationDetail />', () => {
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
});
test('should have proper number of delete detail requests', async () => {
let component;
await act(async () => {
component = mountWithContexts(
<OrganizationDetail organization={mockOrganization} />
);
});
await waitForElement(component, 'ContentLoading', el => el.length === 0);
expect(
component.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(7);
});
test('should render the expected instance group', async () => {
let component;
await act(async () => {

View File

@ -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 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 }
)}
/>,
]}
/>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { OrganizationsAPI } from '../../../api';
import { OrganizationsAPI, CredentialsAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@ -70,6 +70,7 @@ const mockOrganizations = {
describe('<OrganizationsList />', () => {
let wrapper;
beforeEach(() => {
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
OrganizationsAPI.read.mockResolvedValue(mockOrganizations);
OrganizationsAPI.readOptions.mockResolvedValue({
data: {
@ -90,6 +91,20 @@ describe('<OrganizationsList />', () => {
});
});
test('should have proper number of delete detail requests', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationsList />);
});
await waitForElement(
wrapper,
'OrganizationsList',
el => el.find('ContentLoading').length === 0
);
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(7);
});
test('Items are rendered after loading', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationsList />);

View File

@ -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`)}
</DeleteButton>

View File

@ -5,7 +5,12 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { ProjectsAPI } from '../../../api';
import {
ProjectsAPI,
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
InventorySourcesAPI,
} from '../../../api';
import ProjectDetail from './ProjectDetail';
jest.mock('../../../api');
@ -147,6 +152,27 @@ describe('<ProjectDetail />', () => {
expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
});
test('should have proper number of delete detail requests', () => {
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } });
const mockOptions = {
scm_type: '',
scm_clean: false,
scm_delete_on_update: false,
scm_update_on_launch: false,
allow_override: false,
created: '',
modified: '',
};
const wrapper = mountWithContexts(
<ProjectDetail project={{ ...mockProject, ...mockOptions }} />
);
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(3);
});
test('should render with missing summary fields', async () => {
const wrapper = mountWithContexts(
<ProjectDetail project={{ ...mockProject, summary_fields: {} }} />

View File

@ -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 (
<Fragment>
<PageSection>
@ -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 }
)}
/>,
]}
/>

View File

@ -1,7 +1,15 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { ProjectsAPI } from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import {
ProjectsAPI,
JobTemplatesAPI,
WorkflowJobTemplatesAPI,
InventorySourcesAPI,
} from '../../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import ProjectList from './ProjectList';
jest.mock('../../../api');
@ -83,6 +91,9 @@ const mockProjects = [
describe('<ProjectList />', () => {
beforeEach(() => {
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } });
ProjectsAPI.read.mockResolvedValue({
data: {
count: mockProjects.length,
@ -138,6 +149,17 @@ describe('<ProjectList />', () => {
).toEqual(true);
});
test('should have proper number of delete detail requests', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ProjectList />);
});
wrapper.update();
expect(
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
).toHaveLength(3);
});
test('should select all', async () => {
let wrapper;
await act(async () => {
@ -177,10 +199,11 @@ describe('<ProjectList />', () => {
.at(2)
.invoke('onSelect')();
});
wrapper.update();
expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual(
true
waitForElement(
wrapper,
'ToolbarDeleteButton button',
el => el.prop('disabled') === true
);
});

View File

@ -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`)}
</DeleteButton>

View File

@ -5,7 +5,7 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import JobTemplateDetail from './JobTemplateDetail';
import { JobTemplatesAPI } from '../../../api';
import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '../../../api';
import mockTemplate from '../shared/data.job_template.json';
jest.mock('../../../api');
@ -25,6 +25,7 @@ describe('<JobTemplateDetail />', () => {
beforeEach(async () => {
JobTemplatesAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail template={mockTemplate} />
@ -56,6 +57,23 @@ describe('<JobTemplateDetail />', () => {
);
});
test('should have proper number of delete detail requests', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
template={{
...mockTemplate,
become_enabled: true,
summary_fields: { user_capabilities: { delete: true } },
}}
/>
);
});
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(1);
});
test('should request instance groups from api', async () => {
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
});

View File

@ -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 (
<CardBody>
<DetailList gutter="sm">
@ -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`)}
</DeleteButton>

View File

@ -5,6 +5,9 @@ import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
import { WorkflowJobTemplateNodesAPI } from '../../../api';
jest.mock('../../../api');
describe('<WorkflowJobTemplateDetail/>', () => {
let wrapper;
@ -50,6 +53,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
};
beforeEach(async () => {
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1/details'],
});
@ -86,6 +90,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('renders successfully', () => {
@ -163,6 +168,12 @@ describe('<WorkflowJobTemplateDetail/>', () => {
).toBe('Demo EE');
});
test('should have proper number of delete detail requests', async () => {
expect(
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
).toHaveLength(1);
});
test('link out resource have the correct url', () => {
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
const organization = wrapper

View File

@ -0,0 +1,144 @@
import {
getRelatedResourceDeleteCounts,
relatedResourceDeleteRequests,
} from './getRelatedResourceDeleteDetails';
import {
InventoriesAPI,
InventorySourcesAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobTemplatesAPI,
WorkflowJobTemplateNodesAPI,
CredentialsAPI,
} from '../api';
jest.mock('../api/models/Credentials');
jest.mock('../api/models/Inventories');
jest.mock('../api/models/InventorySources');
jest.mock('../api/models/JobTemplates');
jest.mock('../api/models/Projects');
jest.mock('../api/models/WorkflowJobTemplates');
jest.mock('../api/models/WorkflowJobTemplateNodes');
const i18n = {
_: key => {
if (key.values) {
Object.entries(key.values).forEach(([k, v]) => {
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
});
}
return key.id;
},
};
describe('delete details', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should call api for credentials list', () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.credential({ id: 1 }, i18n)
);
expect(InventoriesAPI.read).toBeCalledWith({
insights_credential: 1,
});
expect(InventorySourcesAPI.read).toBeCalledWith({
credentials__id: 1,
});
expect(JobTemplatesAPI.read).toBeCalledWith({ credentials: 1 });
expect(ProjectsAPI.read).toBeCalledWith({ credentials: 1 });
});
test('should call api for projects list', () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.project({ id: 1 }, i18n)
);
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
unified_job_template: 1,
});
expect(InventorySourcesAPI.read).toBeCalledWith({
source_project: 1,
});
expect(JobTemplatesAPI.read).toBeCalledWith({ project: 1 });
});
test('should call api for templates list', () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.template({ id: 1 }, i18n)
);
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
unified_job_template: 1,
});
});
test('should call api for credential type list', () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.credentialType({ id: 1 }, i18n)
);
expect(CredentialsAPI.read).toBeCalledWith({
credential_type__id: 1,
});
});
test('should call api for inventory list', () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.inventory({ id: 1 }, i18n)
);
expect(JobTemplatesAPI.read).toBeCalledWith({ inventory: 1 });
expect(WorkflowJobTemplatesAPI.read).toBeCalledWith({
inventory: 1,
});
});
test('should call api for inventory source list', async () => {
InventoriesAPI.updateSources.mockResolvedValue({
data: [{ inventory_source: 2 }],
});
await getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.inventorySource(1, i18n)
);
expect(InventoriesAPI.updateSources).toBeCalledWith(1);
expect(WorkflowJobTemplateNodesAPI.read).toBeCalledWith({
unified_job_template: 2,
});
});
test('should call api for organization list', async () => {
getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.organization({ id: 1 }, i18n)
);
expect(CredentialsAPI.read).toBeCalledWith({ organization: 1 });
});
test('should call return error for inventory source list', async () => {
InventoriesAPI.updateSources.mockRejectedValue({
response: {
config: {
method: 'post',
url: '/api/v2/inventories/1/ad_hoc_commands',
},
data: 'An error occurred',
status: 403,
},
});
const { error } = await getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.inventorySource(1, i18n)
);
expect(InventoriesAPI.updateSources).toBeCalledWith(1);
expect(error).toBeDefined();
});
test('should return proper results', async () => {
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
ProjectsAPI.read.mockResolvedValue({ data: { count: 2 } });
InventoriesAPI.read.mockResolvedValue({ data: { count: 3 } });
InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } });
const { results } = await getRelatedResourceDeleteCounts(
relatedResourceDeleteRequests.credential({ id: 1 }, i18n)
);
expect(results).toEqual({ Projects: 2, Inventories: 3 });
});
});

View File

@ -0,0 +1,301 @@
import { t } from '@lingui/macro';
import {
UnifiedJobTemplatesAPI,
CredentialsAPI,
InventoriesAPI,
InventorySourcesAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
CredentialInputSourcesAPI,
TeamsAPI,
NotificationTemplatesAPI,
ExecutionEnvironmentsAPI,
ApplicationsAPI,
OrganizationsAPI,
} 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: () =>
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`),
},
{
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) => [
{
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, inventorySource) => [
{
request: async () => {
try {
const { data } = await InventoriesAPI.updateSources(inventoryId);
const results = await Promise.all(
data.map(async datum =>
WorkflowJobTemplateNodesAPI.read({
unified_job_template: datum.inventory_source,
})
)
);
const total = results.reduce(
({ data: { count: acc } }, { data: { count: cur } }) => acc + cur,
{ data: { count: 0 } }
);
return { data: { count: total } };
} catch (err) {
throw new Error(err);
}
},
label: i18n._(t`Workflow Job Template Nodes`),
},
{
request: async () => InventorySourcesAPI.readGroups(inventorySource.id),
label: i18n._(t`Groups`),
},
{
request: async () => InventorySourcesAPI.readHosts(inventorySource.id),
label: i18n._(t`Hosts`),
},
],
project: (selected, i18n) => [
{
request: () =>
JobTemplatesAPI.read({
project: selected.id,
}),
label: i18n._(t`Job Templates`),
},
{
request: () =>
WorkflowJobTemplateNodesAPI.read({
unified_job_template: selected.id,
}),
label: i18n._(t`Workflow Job Templates`),
},
{
request: () =>
InventorySourcesAPI.read({
source_project: 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 Nodes`)],
},
],
organization: (selected, i18n) => [
{
request: async () =>
CredentialsAPI.read({
organization: selected.id,
}),
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({
default_environment: selected.id,
}),
label: [i18n._(t`Organizations`)],
},
{
request: async () => {
try {
const {
data: { results },
} = await InventorySourcesAPI.read({
execution_environment: selected.id,
});
const responses = await Promise.all(
results.map(result =>
WorkflowJobTemplateNodesAPI.read({
unified_job_template: result.id,
})
)
);
const total = responses.reduce(
({ data: { count: acc } }, { data: { count: cur } }) => acc + cur,
{ data: { count: 0 } }
);
return { data: { count: total } };
} catch (err) {
throw new Error(err);
}
},
label: [i18n._(t`Workflow Job Template Nodes`)],
},
],
instanceGroup: (selected, i18n) => [
{
request: () => OrganizationsAPI.read({ instance_groups: selected.id }),
label: i18n._(t`Organizations`),
},
{
request: () => InventoriesAPI.read({ instance_groups: selected.id }),
label: i18n._(t`Inventories`),
},
{
request: () =>
UnifiedJobTemplatesAPI.read({ instance_groups: selected.id }),
label: i18n._(t`Templates`),
},
],
};