Merge pull request #7355 from AlexSCorey/ApplicationDetails

Adds application details view

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-06-24 14:37:21 +00:00 committed by GitHub
commit 038688ca48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 522 additions and 28 deletions

View File

@ -1,26 +1,141 @@
import React from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import React, { useCallback, useEffect } from 'react';
import {
Route,
Switch,
Redirect,
useParams,
useLocation,
Link,
} from 'react-router-dom';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core';
import useRequest from '../../../util/useRequest';
import { ApplicationsAPI } from '../../../api';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import ApplicationEdit from '../ApplicationEdit';
import ApplicationDetails from '../ApplicationDetails';
import RoutedTabs from '../../../components/RoutedTabs';
function Application({ setBreadcrumb, i18n }) {
const { id } = useParams();
const { pathname } = useLocation();
const {
isLoading,
error,
result: { application, authorizationOptions, clientTypeOptions },
request: fetchApplication,
} = useRequest(
useCallback(async () => {
const [detail, options] = await Promise.all([
ApplicationsAPI.readDetail(id),
ApplicationsAPI.readOptions(),
]);
const authorization = options.data.actions.GET.authorization_grant_type.choices.map(
choice => ({
value: choice[0],
label: choice[1],
key: choice[0],
})
);
const clientType = options.data.actions.GET.client_type.choices.map(
choice => ({
value: choice[0],
label: choice[1],
key: choice[0],
})
);
setBreadcrumb(detail.data);
return {
application: detail.data,
authorizationOptions: authorization,
clientTypeOptions: clientType,
};
}, [setBreadcrumb, id]),
{ authorizationOptions: [], clientTypeOptions: [] }
);
useEffect(() => {
fetchApplication();
}, [fetchApplication, pathname]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to applications`)}
</>
),
link: '/applications',
id: 0,
},
{ name: i18n._(t`Details`), link: `/applications/${id}/details`, id: 1 },
{ name: i18n._(t`Tokens`), link: `/applications/${id}/tokens`, id: 2 },
];
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
if (pathname.endsWith('edit')) {
cardHeader = null;
}
if (!isLoading && error) {
return (
<PageSection>
<Card>
<ContentError error={error}>
{error.response?.status === 404 && (
<span>
{i18n._(`Application not found.`)}{' '}
<Link to="/applications">
{i18n._(`View all applications.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (isLoading) {
return <ContentLoading />;
}
function Application() {
return (
<>
<Switch>
<Redirect
from="/applications/:id"
to="/applications/:id/details"
exact
/>
<Route path="/applications/:id/edit">
<ApplicationEdit />
</Route>
<Route path="/applications/:id/details">
<ApplicationDetails />
</Route>
</Switch>
</>
<PageSection>
<Card>
{cardHeader}
<Switch>
<Redirect
from="/applications/:id"
to="/applications/:id/details"
exact
/>
{application && (
<>
<Route path="/applications/:id/edit">
<ApplicationEdit
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
application={application}
/>
</Route>
<Route path="/applications/:id/details">
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
</Route>
</>
)}
</Switch>
</Card>
</PageSection>
);
}
export default Application;
export default withI18n()(Application);

View File

@ -0,0 +1,85 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { ApplicationsAPI } from '../../../api';
import Application from './Application';
jest.mock('../../../api/models/Applications');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
history: () => ({
location: '/applications',
}),
useParams: () => ({ id: 1 }),
}));
const options = {
data: {
actions: {
GET: {
client_type: {
choices: [
['confidential', 'Confidential'],
['public', 'Public'],
],
},
authorization_grant_type: {
choices: [
['authorization-code', 'Authorization code'],
['password', 'Resource owner password-based'],
],
},
},
},
},
};
const application = {
id: 1,
name: 'Foo',
summary_fields: {
organization: { name: 'Org 1', id: 10 },
user_capabilities: { edit: true, delete: true },
},
url: '',
organization: 10,
};
describe('<Application />', () => {
let wrapper;
test('mounts properly', async () => {
ApplicationsAPI.readOptions.mockResolvedValue(options);
ApplicationsAPI.readDetail.mockResolvedValue(application);
await act(async () => {
wrapper = mountWithContexts(<Application setBreadcrumb={() => {}} />);
});
expect(wrapper.find('Application').length).toBe(1);
expect(ApplicationsAPI.readOptions).toBeCalled();
expect(ApplicationsAPI.readDetail).toBeCalledWith(1);
});
test('should throw error', async () => {
ApplicationsAPI.readDetail.mockRejectedValue(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/applications/1',
},
data: 'An error occurred',
status: 404,
},
})
);
ApplicationsAPI.readOptions.mockResolvedValue(options);
await act(async () => {
wrapper = mountWithContexts(<Application setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
expect(wrapper.find('ContentError').length).toBe(1);
expect(wrapper.find('ApplicationAdd').length).toBe(0);
expect(wrapper.find('ApplicationDetails').length).toBe(0);
expect(wrapper.find('Application').length).toBe(1);
expect(ApplicationsAPI.readOptions).toBeCalled();
expect(ApplicationsAPI.readDetail).toBeCalledWith(1);
});
});

View File

@ -1,11 +1,128 @@
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
function ApplicationDetails() {
import { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import { Detail, DetailList } from '../../../components/DetailList';
import { ApplicationsAPI } from '../../../api';
import DeleteButton from '../../../components/DeleteButton';
import ErrorDetail from '../../../components/ErrorDetail';
function ApplicationDetails({
i18n,
application,
authorizationOptions,
clientTypeOptions,
}) {
const history = useHistory();
const {
isLoading: deleteLoading,
deletionError,
deleteItems: handleDeleteApplications,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
await ApplicationsAPI.destroy(application.id);
history.push('/applications');
}, [application.id, history])
);
const getAuthorizationGrantType = type => {
let value;
authorizationOptions.filter(option => {
if (option.value === type) {
value = option.label;
}
return null;
});
return value;
};
const getClientType = type => {
let value;
clientTypeOptions.filter(option => {
if (option.value === type) {
value = option.label;
}
return null;
});
return value;
};
return (
<PageSection>
<Card>Application Details</Card>
</PageSection>
<CardBody>
<DetailList>
<Detail
label={i18n._(t`Name`)}
value={application.name}
dataCy="jt-detail-name"
/>
<Detail
label={i18n._(t`Description`)}
value={application.description}
/>
<Detail
label={i18n._(t`Organization`)}
value={
<Link
to={`/organizations/${application.summary_fields.organization.id}/details`}
>
{application.summary_fields.organization.name}
</Link>
}
/>
<Detail
label={i18n._(t`Authorization grant type`)}
value={getAuthorizationGrantType(
application.authorization_grant_type
)}
/>
<Detail
label={i18n._(t`Redirect uris`)}
value={application.redirect_uris}
/>
<Detail
label={i18n._(t`Client type`)}
value={getClientType(application.client_type)}
/>
</DetailList>
<CardActionsRow>
{application.summary_fields.user_capabilities &&
application.summary_fields.user_capabilities.edit && (
<Button
component={Link}
to={`/applications/${application.id}/edit`}
aria-label={i18n._(t`Edit`)}
>
{i18n._(t`Edit`)}
</Button>
)}
{application.summary_fields.user_capabilities &&
application.summary_fields.user_capabilities.delete && (
<DeleteButton
name={application.name}
modalTitle={i18n._(t`Delete application`)}
onConfirm={handleDeleteApplications}
isDisabled={deleteLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete application.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</CardBody>
);
}
export default ApplicationDetails;
export default withI18n()(ApplicationDetails);

View File

@ -0,0 +1,173 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { ApplicationsAPI } from '../../../api';
import ApplicationDetails from './ApplicationDetails';
jest.mock('../../../api/models/Applications');
const application = {
id: 10,
type: 'o_auth2_application',
url: '/api/v2/applications/10/',
related: {
named_url: '/api/v2/applications/Alex++bar/',
tokens: '/api/v2/applications/10/tokens/',
activity_stream: '/api/v2/applications/10/activity_stream/',
},
summary_fields: {
organization: {
id: 230,
name: 'bar',
description:
'SaleNameBedPersonalityManagerWhileFinanceBreakToothPerson魲',
},
user_capabilities: {
edit: true,
delete: true,
},
tokens: {
count: 2,
results: [
{
id: 1,
token: '************',
scope: 'read',
},
{
id: 2,
token: '************',
scope: 'write',
},
],
},
},
created: '2020-06-11T17:54:33.983993Z',
modified: '2020-06-11T17:54:33.984039Z',
name: 'Alex',
description: 'foo',
client_id: 'b1dmj8xzkbFm1ZQ27ygw2ZeE9I0AXqqeL74fiyk4',
client_secret: '************',
client_type: 'confidential',
redirect_uris: 'http://www.google.com',
authorization_grant_type: 'authorization-code',
skip_authorization: false,
organization: 230,
};
const authorizationOptions = [
{
key: 'authorization-code',
label: 'Authorization code',
value: 'authorization-code',
},
{
key: 'password',
label: 'Resource owner password-based',
value: 'password',
},
];
const clientTypeOptions = [
{ key: 'confidential', label: 'Confidential', value: 'confidential' },
{ key: 'public', label: 'Public', value: 'public' },
];
describe('<ApplicationDetails/>', () => {
let wrapper;
test('should mount properly', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
);
});
expect(wrapper.find('ApplicationDetails').length).toBe(1);
});
test('should render proper data', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
);
});
expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Alex');
expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe(
'foo'
);
expect(wrapper.find('Detail[label="Organization"]').length).toBe(1);
expect(
wrapper
.find('Link')
.at(0)
.prop('to')
).toBe('/organizations/230/details');
expect(
wrapper.find('Detail[label="Authorization grant type"]').prop('value')
).toBe('Authorization code');
expect(wrapper.find('Detail[label="Redirect uris"]').prop('value')).toBe(
'http://www.google.com'
);
expect(wrapper.find('Detail[label="Client type"]').prop('value')).toBe(
'Confidential'
);
expect(wrapper.find('Button[aria-label="Edit"]').prop('to')).toBe(
'/applications/10/edit'
);
expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1);
});
test('should delete properly', async () => {
ApplicationsAPI.destroy.mockResolvedValue({ data: {} });
await act(async () => {
wrapper = mountWithContexts(
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
);
});
await act(async () =>
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
);
wrapper.update();
await act(async () => wrapper.find('DeleteButton').prop('onConfirm')());
expect(ApplicationsAPI.destroy).toBeCalledWith(10);
});
test(' should not render delete button', async () => {
application.summary_fields.user_capabilities.delete = false;
await act(async () => {
wrapper = mountWithContexts(
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
);
});
expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0);
});
test(' should not render edit button', async () => {
application.summary_fields.user_capabilities.edit = false;
await act(async () => {
wrapper = mountWithContexts(
<ApplicationDetails
application={application}
authorizationOptions={authorizationOptions}
clientTypeOptions={clientTypeOptions}
/>
);
});
expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
});
});

View File

@ -23,11 +23,15 @@ function Applications({ i18n }) {
setBreadcrumbConfig({
'/applications': i18n._(t`Applications`),
'/applications/add': i18n._(t`Create New Application`),
[`/application/${application.id}`]: `${application.name}`,
[`/applications/${application.id}`]: `${application.name}`,
[`/applications/${application.id}/edit`]: i18n._(t`Edit Details`),
[`/applications/${application.id}/details`]: i18n._(t`Details`),
[`/applications/${application.id}/tokens`]: i18n._(t`Tokens`),
});
},
[i18n]
);
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />