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 ( <>