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:
softwarefactory-project-zuul[bot] 2020-01-23 20:40:36 +00:00 committed by GitHub
commit 145476c7d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 204 additions and 12 deletions

View File

@ -1,5 +1,5 @@
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 { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
@ -7,8 +7,11 @@ import { OrganizationsAPI } from '@api';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { CardBody, CardActionsRow } from '@components/Card';
import { ChipGroup, Chip } from '@components/Chip';
import AlertModal from '@components/AlertModal';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import DeleteButton from '@components/DeleteButton';
import ErrorDetail from '@components/ErrorDetail';
function OrganizationDetail({ i18n, organization }) {
const {
@ -24,8 +27,10 @@ function OrganizationDetail({ i18n, organization }) {
summary_fields,
} = organization;
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const [instanceGroups, setInstanceGroups] = useState([]);
const history = useHistory();
useEffect(() => {
(async () => {
@ -44,6 +49,17 @@ function OrganizationDetail({ i18n, organization }) {
})();
}, [id]);
const handleDelete = async () => {
setHasContentLoading(true);
try {
await OrganizationsAPI.destroy(id);
history.push(`/organizations`);
} catch (error) {
setDeletionError(error);
}
setHasContentLoading(false);
};
if (hasContentLoading) {
return <ContentLoading />;
}
@ -94,11 +110,37 @@ function OrganizationDetail({ i18n, organization }) {
</DetailList>
<CardActionsRow>
{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`)}
</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>
{/* 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>
);
}

View File

@ -19,6 +19,7 @@ describe('<OrganizationDetail />', () => {
summary_fields: {
user_capabilities: {
edit: true,
delete: true,
},
},
};
@ -98,7 +99,7 @@ describe('<OrganizationDetail />', () => {
});
const editButton = await waitForElement(
wrapper,
'OrganizationDetail Button'
'OrganizationDetail Button[aria-label="Edit"]'
);
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/organizations/undefined/edit');
@ -115,6 +116,74 @@ describe('<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
);
});
});

View File

@ -1,13 +1,19 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Project } from '@types';
import { Config } from '@contexts/Config';
import { Button, List, ListItem } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import { CardBody, CardActionsRow } from '@components/Card';
import ContentLoading from '@components/ContentLoading';
import DeleteButton from '@components/DeleteButton';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import ErrorDetail from '@components/ErrorDetail';
import { CredentialChip } from '@components/Chip';
import { ProjectsAPI } from '@api';
import { toTitleCase } from '@util/strings';
function ProjectDetail({ project, i18n }) {
@ -30,6 +36,20 @@ function ProjectDetail({ project, i18n }) {
scm_url,
summary_fields,
} = 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 = '';
if (
@ -54,6 +74,10 @@ function ProjectDetail({ project, i18n }) {
);
}
if (hasContentLoading) {
return <ContentLoading />;
}
return (
<CardBody>
<DetailList gutter="sm">
@ -138,7 +162,29 @@ function ProjectDetail({ project, i18n }) {
{i18n._(t`Edit`)}
</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>
{/* 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>
);
}

View File

@ -1,8 +1,12 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { ProjectsAPI } from '@api';
import ProjectDetail from './ProjectDetail';
jest.mock('@api');
describe('<ProjectDetail />', () => {
const mockProject = {
id: 1,
@ -122,6 +126,7 @@ describe('<ProjectDetail />', () => {
test('should hide options label when all project options return false', () => {
const mockOptions = {
scm_type: '',
scm_clean: false,
scm_delete_on_update: false,
scm_update_on_launch: false,
@ -135,7 +140,7 @@ describe('<ProjectDetail />', () => {
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(
<ProjectDetail project={{ ...mockProject, summary_fields: {} }} />
);
@ -144,10 +149,9 @@ describe('<ProjectDetail />', () => {
'Detail[label="Name"]',
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 editButton = await waitForElement(
wrapper,
@ -155,10 +159,9 @@ describe('<ProjectDetail />', () => {
);
expect(editButton.text()).toEqual('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(
<ProjectDetail
project={{
@ -175,7 +178,6 @@ describe('<ProjectDetail />', () => {
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
0
);
done();
});
test('edit button should navigate to project edit', () => {
@ -189,4 +191,37 @@ describe('<ProjectDetail />', () => {
.simulate('click', { button: 0 });
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
);
});
});