mirror of
https://github.com/ansible/awx.git
synced 2026-05-16 13:57:39 -02:30
Merge pull request #5748 from marshmalien/delete-org-proj-details
Add delete button to Organization and Project Details Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useRouteMatch } from 'react-router-dom';
|
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { Button } from '@patternfly/react-core';
|
||||||
@@ -7,8 +7,11 @@ import { OrganizationsAPI } from '@api';
|
|||||||
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
|
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
|
||||||
import { CardBody, CardActionsRow } from '@components/Card';
|
import { CardBody, CardActionsRow } from '@components/Card';
|
||||||
import { ChipGroup, Chip } from '@components/Chip';
|
import { ChipGroup, Chip } from '@components/Chip';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import DeleteButton from '@components/DeleteButton';
|
||||||
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
|
|
||||||
function OrganizationDetail({ i18n, organization }) {
|
function OrganizationDetail({ i18n, organization }) {
|
||||||
const {
|
const {
|
||||||
@@ -24,8 +27,10 @@ function OrganizationDetail({ i18n, organization }) {
|
|||||||
summary_fields,
|
summary_fields,
|
||||||
} = organization;
|
} = organization;
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
|
const [deletionError, setDeletionError] = useState(null);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
const [hasContentLoading, setHasContentLoading] = useState(true);
|
||||||
const [instanceGroups, setInstanceGroups] = useState([]);
|
const [instanceGroups, setInstanceGroups] = useState([]);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -44,6 +49,17 @@ function OrganizationDetail({ i18n, organization }) {
|
|||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setHasContentLoading(true);
|
||||||
|
try {
|
||||||
|
await OrganizationsAPI.destroy(id);
|
||||||
|
history.push(`/organizations`);
|
||||||
|
} catch (error) {
|
||||||
|
setDeletionError(error);
|
||||||
|
}
|
||||||
|
setHasContentLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
@@ -94,11 +110,37 @@ function OrganizationDetail({ i18n, organization }) {
|
|||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{summary_fields.user_capabilities.edit && (
|
{summary_fields.user_capabilities.edit && (
|
||||||
<Button component={Link} to={`/organizations/${id}/edit`}>
|
<Button
|
||||||
|
aria-label={i18n._(t`Edit`)}
|
||||||
|
component={Link}
|
||||||
|
to={`/organizations/${id}/edit`}
|
||||||
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{summary_fields.user_capabilities &&
|
||||||
|
summary_fields.user_capabilities.delete && (
|
||||||
|
<DeleteButton
|
||||||
|
name={name}
|
||||||
|
modalTitle={i18n._(t`Delete Organization`)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
>
|
||||||
|
{i18n._(t`Delete`)}
|
||||||
|
</DeleteButton>
|
||||||
|
)}
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
|
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||||
|
{deletionError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deletionError}
|
||||||
|
variant="danger"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={() => setDeletionError(null)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to delete organization.`)}
|
||||||
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('<OrganizationDetail />', () => {
|
|||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
edit: true,
|
edit: true,
|
||||||
|
delete: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -98,7 +99,7 @@ describe('<OrganizationDetail />', () => {
|
|||||||
});
|
});
|
||||||
const editButton = await waitForElement(
|
const editButton = await waitForElement(
|
||||||
wrapper,
|
wrapper,
|
||||||
'OrganizationDetail Button'
|
'OrganizationDetail Button[aria-label="Edit"]'
|
||||||
);
|
);
|
||||||
expect(editButton.text()).toEqual('Edit');
|
expect(editButton.text()).toEqual('Edit');
|
||||||
expect(editButton.prop('to')).toBe('/organizations/undefined/edit');
|
expect(editButton.prop('to')).toBe('/organizations/undefined/edit');
|
||||||
@@ -115,6 +116,74 @@ describe('<OrganizationDetail />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'OrganizationDetail');
|
await waitForElement(wrapper, 'OrganizationDetail');
|
||||||
expect(wrapper.find('OrganizationDetail Button').length).toBe(0);
|
expect(
|
||||||
|
wrapper.find('OrganizationDetail Button[aria-label="Edit"]').length
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expected api calls are made for delete', async () => {
|
||||||
|
OrganizationsAPI.readInstanceGroups.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OrganizationDetail organization={mockOrganization} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'OrganizationDetail Button[aria-label="Delete"]'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
expect(OrganizationsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error for failed instance group fetch', async () => {
|
||||||
|
OrganizationsAPI.readInstanceGroups.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OrganizationDetail organization={mockOrganization} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
|
OrganizationsAPI.destroy.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<OrganizationDetail organization={mockOrganization} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'OrganizationDetail Button[aria-label="Delete"]'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 1
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 0
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Project } from '@types';
|
import { Project } from '@types';
|
||||||
import { Config } from '@contexts/Config';
|
import { Config } from '@contexts/Config';
|
||||||
|
|
||||||
import { Button, List, ListItem } from '@patternfly/react-core';
|
import { Button, List, ListItem } from '@patternfly/react-core';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from '@components/Card';
|
import { CardBody, CardActionsRow } from '@components/Card';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import DeleteButton from '@components/DeleteButton';
|
||||||
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
|
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
|
||||||
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
import { CredentialChip } from '@components/Chip';
|
import { CredentialChip } from '@components/Chip';
|
||||||
|
import { ProjectsAPI } from '@api';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
|
|
||||||
function ProjectDetail({ project, i18n }) {
|
function ProjectDetail({ project, i18n }) {
|
||||||
@@ -30,6 +36,20 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
scm_url,
|
scm_url,
|
||||||
summary_fields,
|
summary_fields,
|
||||||
} = project;
|
} = project;
|
||||||
|
const [deletionError, setDeletionError] = useState(null);
|
||||||
|
const [hasContentLoading, setHasContentLoading] = useState(false);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setHasContentLoading(true);
|
||||||
|
try {
|
||||||
|
await ProjectsAPI.destroy(id);
|
||||||
|
history.push(`/projects`);
|
||||||
|
} catch (error) {
|
||||||
|
setDeletionError(error);
|
||||||
|
}
|
||||||
|
setHasContentLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
let optionsList = '';
|
let optionsList = '';
|
||||||
if (
|
if (
|
||||||
@@ -54,6 +74,10 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasContentLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList gutter="sm">
|
<DetailList gutter="sm">
|
||||||
@@ -138,7 +162,29 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{summary_fields.user_capabilities &&
|
||||||
|
summary_fields.user_capabilities.delete && (
|
||||||
|
<DeleteButton
|
||||||
|
name={name}
|
||||||
|
modalTitle={i18n._(t`Delete Project`)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
>
|
||||||
|
{i18n._(t`Delete`)}
|
||||||
|
</DeleteButton>
|
||||||
|
)}
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
|
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||||
|
{deletionError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deletionError}
|
||||||
|
variant="danger"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={() => setDeletionError(null)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to delete project.`)}
|
||||||
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
import { ProjectsAPI } from '@api';
|
||||||
import ProjectDetail from './ProjectDetail';
|
import ProjectDetail from './ProjectDetail';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<ProjectDetail />', () => {
|
describe('<ProjectDetail />', () => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -122,6 +126,7 @@ describe('<ProjectDetail />', () => {
|
|||||||
|
|
||||||
test('should hide options label when all project options return false', () => {
|
test('should hide options label when all project options return false', () => {
|
||||||
const mockOptions = {
|
const mockOptions = {
|
||||||
|
scm_type: '',
|
||||||
scm_clean: false,
|
scm_clean: false,
|
||||||
scm_delete_on_update: false,
|
scm_delete_on_update: false,
|
||||||
scm_update_on_launch: false,
|
scm_update_on_launch: false,
|
||||||
@@ -135,7 +140,7 @@ describe('<ProjectDetail />', () => {
|
|||||||
expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Options"]').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render with missing summary fields', async done => {
|
test('should render with missing summary fields', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<ProjectDetail project={{ ...mockProject, summary_fields: {} }} />
|
<ProjectDetail project={{ ...mockProject, summary_fields: {} }} />
|
||||||
);
|
);
|
||||||
@@ -144,10 +149,9 @@ describe('<ProjectDetail />', () => {
|
|||||||
'Detail[label="Name"]',
|
'Detail[label="Name"]',
|
||||||
el => el.length === 1
|
el => el.length === 1
|
||||||
);
|
);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show edit button for users with edit permission', async done => {
|
test('should show edit button for users with edit permission', async () => {
|
||||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
const editButton = await waitForElement(
|
const editButton = await waitForElement(
|
||||||
wrapper,
|
wrapper,
|
||||||
@@ -155,10 +159,9 @@ describe('<ProjectDetail />', () => {
|
|||||||
);
|
);
|
||||||
expect(editButton.text()).toEqual('Edit');
|
expect(editButton.text()).toEqual('Edit');
|
||||||
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should hide edit button for users without edit permission', async done => {
|
test('should hide edit button for users without edit permission', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<ProjectDetail
|
<ProjectDetail
|
||||||
project={{
|
project={{
|
||||||
@@ -175,7 +178,6 @@ describe('<ProjectDetail />', () => {
|
|||||||
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button should navigate to project edit', () => {
|
test('edit button should navigate to project edit', () => {
|
||||||
@@ -189,4 +191,37 @@ describe('<ProjectDetail />', () => {
|
|||||||
.simulate('click', { button: 0 });
|
.simulate('click', { button: 0 });
|
||||||
expect(history.location.pathname).toEqual('/projects/1/edit');
|
expect(history.location.pathname).toEqual('/projects/1/edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('expected api calls are made for delete', async () => {
|
||||||
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
|
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
|
ProjectsAPI.destroy.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
|
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 1
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user