diff --git a/awx/ui_next/src/screens/Application/Application/Application.jsx b/awx/ui_next/src/screens/Application/Application/Application.jsx
index 5a002f2990..dabce2167f 100644
--- a/awx/ui_next/src/screens/Application/Application/Application.jsx
+++ b/awx/ui_next/src/screens/Application/Application/Application.jsx
@@ -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: (
+ <>
+
+ {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 = ;
+ if (pathname.endsWith('edit')) {
+ cardHeader = null;
+ }
+ if (!isLoading && error) {
+ return (
+
+
+
+ {error.response?.status === 404 && (
+
+ {i18n._(`Application not found.`)}{' '}
+
+ {i18n._(`View all applications.`)}
+
+
+ )}
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
-function Application() {
return (
- <>
-
-
-
-
-
-
-
-
-
- >
+
+
+ {cardHeader}
+
+
+ {application && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
);
}
-
-export default Application;
+export default withI18n()(Application);
diff --git a/awx/ui_next/src/screens/Application/Application/Application.test.jsx b/awx/ui_next/src/screens/Application/Application/Application.test.jsx
new file mode 100644
index 0000000000..8b8f8fba5b
--- /dev/null
+++ b/awx/ui_next/src/screens/Application/Application/Application.test.jsx
@@ -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('', () => {
+ let wrapper;
+ test('mounts properly', async () => {
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ ApplicationsAPI.readDetail.mockResolvedValue(application);
+ await act(async () => {
+ wrapper = mountWithContexts( {}} />);
+ });
+ 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( {}} />);
+ });
+ 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);
+ });
+});
diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
index c8051841bb..8cd20a4759 100644
--- a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
+++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
@@ -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 (
-
- Application Details
-
+
+
+
+
+
+ {application.summary_fields.organization.name}
+
+ }
+ />
+
+
+
+
+
+ {application.summary_fields.user_capabilities &&
+ application.summary_fields.user_capabilities.edit && (
+
+ )}
+ {application.summary_fields.user_capabilities &&
+ application.summary_fields.user_capabilities.delete && (
+
+ {i18n._(t`Delete`)}
+
+ )}
+
+ {deletionError && (
+
+ {i18n._(t`Failed to delete application.`)}
+
+
+ )}
+
);
}
-export default ApplicationDetails;
+export default withI18n()(ApplicationDetails);
diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx
new file mode 100644
index 0000000000..8c6339c78d
--- /dev/null
+++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx
@@ -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('', () => {
+ let wrapper;
+ test('should mount properly', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('ApplicationDetails').length).toBe(1);
+ });
+
+ test('should render proper data', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ 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(
+
+ );
+ });
+ 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(
+
+ );
+ });
+ 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(
+
+ );
+ });
+ expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Application/Applications.jsx b/awx/ui_next/src/screens/Application/Applications.jsx
index 19a23be08e..d25245095e 100644
--- a/awx/ui_next/src/screens/Application/Applications.jsx
+++ b/awx/ui_next/src/screens/Application/Applications.jsx
@@ -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 (
<>