mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
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:
commit
038688ca48
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user