diff --git a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx index 0537fa813c..34f40645d7 100644 --- a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx @@ -8,7 +8,7 @@ import ApplicationForm from '../shared/ApplicationForm'; import { ApplicationsAPI } from '../../../api'; import { CardBody } from '../../../components/Card'; -function ApplicationAdd() { +function ApplicationAdd({ onSuccessfulAdd }) { const history = useHistory(); const [submitError, setSubmitError] = useState(null); @@ -53,10 +53,9 @@ function ApplicationAdd() { const handleSubmit = async ({ ...values }) => { values.organization = values.organization.id; try { - const { - data: { id }, - } = await ApplicationsAPI.create(values); - history.push(`/applications/${id}/details`); + const { data } = await ApplicationsAPI.create(values); + onSuccessfulAdd(data); + history.push(`/applications/${data.id}/details`); } catch (err) { setSubmitError(err); } diff --git a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx index 2bc0eba47e..dd480e8ab9 100644 --- a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx @@ -39,12 +39,16 @@ const options = { }, }; +const onSuccessfulAdd = jest.fn(); + describe('', () => { let wrapper; test('should render properly', async () => { ApplicationsAPI.readOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); expect(wrapper.find('ApplicationAdd').length).toBe(1); expect(wrapper.find('ApplicationForm').length).toBe(1); @@ -59,9 +63,12 @@ describe('', () => { ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); }); await act(async () => { @@ -124,6 +131,7 @@ describe('', () => { redirect_uris: 'http://www.google.com', }); expect(history.location.pathname).toBe('/applications/8/details'); + expect(onSuccessfulAdd).toHaveBeenCalledWith({ id: 8 }); }); test('should cancel form properly', async () => { @@ -134,9 +142,12 @@ describe('', () => { ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); }); await act(async () => { wrapper.find('Button[aria-label="Cancel"]').prop('onClick')(); @@ -157,7 +168,9 @@ describe('', () => { ApplicationsAPI.create.mockRejectedValue(error); ApplicationsAPI.readOptions.mockResolvedValue(options); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); await act(async () => { wrapper.find('Formik').prop('onSubmit')({ @@ -181,7 +194,9 @@ describe('', () => { }) ); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); wrapper.update(); diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx index e6874b65a5..5e55cb1611 100644 --- a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx @@ -58,11 +58,12 @@ function ApplicationDetails({ } + dataCy="app-detail-organization" /> + diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx index 27fdb5f2f4..eebefb0ce0 100644 --- a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.test.jsx @@ -120,6 +120,9 @@ describe('', () => { expect(wrapper.find('Detail[label="Client type"]').prop('value')).toBe( 'Confidential' ); + expect(wrapper.find('Detail[label="Client ID"]').prop('value')).toBe( + 'b1dmj8xzkbFm1ZQ27ygw2ZeE9I0AXqqeL74fiyk4' + ); expect(wrapper.find('Button[aria-label="Edit"]').prop('to')).toBe( '/applications/10/edit' ); diff --git a/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.jsx b/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.jsx index 18268ba63a..13558bd2a0 100644 --- a/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useHistory, useParams } from 'react-router-dom'; -import { Card, PageSection } from '@patternfly/react-core'; +import { Card } from '@patternfly/react-core'; import ApplicationForm from '../shared/ApplicationForm'; import { ApplicationsAPI } from '../../../api'; import { CardBody } from '../../../components/Card'; @@ -29,22 +29,18 @@ function ApplicationEdit({ history.push(`/applications/${id}/details`); }; return ( - <> - - - - - - - - + + + + + ); } export default ApplicationEdit; diff --git a/awx/ui_next/src/screens/Application/Applications.jsx b/awx/ui_next/src/screens/Application/Applications.jsx index 1842e5bd89..85995c8512 100644 --- a/awx/ui_next/src/screens/Application/Applications.jsx +++ b/awx/ui_next/src/screens/Application/Applications.jsx @@ -1,14 +1,26 @@ import React, { useState, useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import styled from 'styled-components'; import { Route, Switch } from 'react-router-dom'; - +import { + Alert, + ClipboardCopy, + ClipboardCopyVariant, + Modal, +} from '@patternfly/react-core'; import ApplicationsList from './ApplicationsList'; import ApplicationAdd from './ApplicationAdd'; import Application from './Application'; import Breadcrumbs from '../../components/Breadcrumbs'; +import { Detail, DetailList } from '../../components/DetailList'; + +const ApplicationAlert = styled(Alert)` + margin-bottom: 20px; +`; function Applications({ i18n }) { + const [applicationModalSource, setApplicationModalSource] = useState(null); const [breadcrumbConfig, setBreadcrumbConfig] = useState({ '/applications': i18n._(t`Applications`), '/applications/add': i18n._(t`Create New Application`), @@ -36,7 +48,9 @@ function Applications({ i18n }) { - + setApplicationModalSource(app)} + /> @@ -45,6 +59,57 @@ function Applications({ i18n }) { + {applicationModalSource && ( + setApplicationModalSource(null)} + > + {applicationModalSource.client_secret && ( + + )} + + + {applicationModalSource.client_id && ( + + {applicationModalSource.client_id} + + } + /> + )} + {applicationModalSource.client_secret && ( + + {applicationModalSource.client_secret} + + } + /> + )} + + + )} ); } diff --git a/awx/ui_next/src/screens/Application/Applications.test.jsx b/awx/ui_next/src/screens/Application/Applications.test.jsx index f309a2b60a..bb75e55deb 100644 --- a/awx/ui_next/src/screens/Application/Applications.test.jsx +++ b/awx/ui_next/src/screens/Application/Applications.test.jsx @@ -1,25 +1,48 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import Applications from './Applications'; describe('', () => { - let pageWrapper; - let pageSections; - - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - }); + let wrapper; afterEach(() => { - pageWrapper.unmount(); + wrapper.unmount(); }); - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); + test('renders successfully', () => { + wrapper = mountWithContexts(); + const pageSections = wrapper.find('PageSection'); + expect(wrapper.length).toBe(1); expect(pageSections.length).toBe(1); expect(pageSections.first().props().variant).toBe('light'); }); + + test('shows Application information modal after successful creation', async () => { + const history = createMemoryHistory({ + initialEntries: ['/applications/add'], + }); + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + expect(wrapper.find('Modal[title="Application information"]').length).toBe( + 0 + ); + await act(async () => { + wrapper + .find('ApplicationAdd') + .props() + .onSuccessfulAdd({ + name: 'test', + client_id: 'foobar', + client_secret: 'aaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + }); + wrapper.update(); + expect(wrapper.find('Modal[title="Application information"]').length).toBe( + 1 + ); + }); }); diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index c29948fe67..7fd0647b33 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -14,7 +14,6 @@ import { Card, PageSection } from '@patternfly/react-core'; import useRequest from '../../util/useRequest'; import { UsersAPI } from '../../api'; import ContentError from '../../components/ContentError'; -import ContentLoading from '../../components/ContentLoading'; import RoutedTabs from '../../components/RoutedTabs'; import UserDetail from './UserDetail'; import UserEdit from './UserEdit'; @@ -86,7 +85,7 @@ function User({ i18n, setBreadcrumb, me }) { showCardHeader = false; } - if (contentError) { + if (!isLoading && contentError) { return ( @@ -107,41 +106,34 @@ function User({ i18n, setBreadcrumb, me }) { {showCardHeader && } - {isLoading && } - {!isLoading && user && ( - - - {user && ( - - - - )} - {user && ( - - - - )} - + + + {user && [ + + + , + + + , + - - {user && ( - - - - )} - {user && ( - - - - )} - + , + + + , + + + , + - - + , + ]} + + {!isLoading && ( {match.params.id && ( @@ -149,9 +141,9 @@ function User({ i18n, setBreadcrumb, me }) { )} - - - )} + )} + + ); diff --git a/awx/ui_next/src/screens/User/User.test.jsx b/awx/ui_next/src/screens/User/User.test.jsx index 5765aa2158..9046d4052c 100644 --- a/awx/ui_next/src/screens/User/User.test.jsx +++ b/awx/ui_next/src/screens/User/User.test.jsx @@ -22,10 +22,11 @@ async function getUsers() { }; } +UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); +UsersAPI.read.mockImplementation(getUsers); + describe('', () => { test('initially renders successfully', async () => { - UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); - UsersAPI.read.mockImplementation(getUsers); const history = createMemoryHistory({ initialEntries: ['/users/1'], }); @@ -49,8 +50,6 @@ describe('', () => { }); test('tabs shown for users', async () => { - UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); - UsersAPI.read.mockImplementation(getUsers); const history = createMemoryHistory({ initialEntries: ['/users/1'], }); @@ -81,9 +80,7 @@ describe('', () => { expect(wrapper.find('Tabs TabButton').length).toEqual(6); }); - test('should not now Tokens tab', async () => { - UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); - UsersAPI.read.mockImplementation(getUsers); + test('should not show Tokens tab', async () => { const history = createMemoryHistory({ initialEntries: ['/users/1'], }); diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx index af6f0f0b16..e42a98b5a4 100644 --- a/awx/ui_next/src/screens/User/UserToken/UserToken.jsx +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx @@ -24,20 +24,16 @@ function UserToken({ i18n, setBreadcrumb, user }) { isLoading, error, request: fetchToken, - result: { token, actions }, + result: { token }, } = useRequest( useCallback(async () => { - const [response, actionsResponse] = await Promise.all([ - TokensAPI.readDetail(tokenId), - TokensAPI.readOptions(), - ]); + const response = await TokensAPI.readDetail(tokenId); setBreadcrumb(user, response.data); return { token: response.data, - actions: actionsResponse.data.actions.POST, }; }, [setBreadcrumb, user, tokenId]), - { token: null, actions: null } + { token: null } ); useEffect(() => { fetchToken(); @@ -97,7 +93,7 @@ function UserToken({ i18n, setBreadcrumb, user }) { /> {token && ( - + )} diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx index 8e71f1b085..f8e551c7a7 100644 --- a/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx @@ -54,9 +54,6 @@ describe('', () => { description: 'cdfsg', scope: 'read', }); - TokensAPI.readOptions.mockResolvedValue({ - data: { actions: { POST: true } }, - }); await act(async () => { wrapper = mountWithContexts( @@ -87,15 +84,11 @@ describe('', () => { description: 'cdfsg', scope: 'read', }); - TokensAPI.readOptions.mockResolvedValue({ - data: { actions: { POST: true } }, - }); await act(async () => { wrapper = mountWithContexts( ); }); expect(TokensAPI.readDetail).toBeCalledWith(2); - expect(TokensAPI.readOptions).toBeCalled(); }); }); diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx index 606171c028..ae7ea9a84f 100644 --- a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx @@ -6,22 +6,27 @@ import { TokensAPI, UsersAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; import UserTokenFrom from '../shared/UserTokenForm'; -function UserTokenAdd() { +function UserTokenAdd({ onSuccessfulAdd }) { const history = useHistory(); const { id: userId } = useParams(); const { error: submitError, request: handleSubmit } = useRequest( useCallback( async formData => { + let response; if (formData.application) { - formData.application = formData.application?.id || null; - await UsersAPI.createToken(userId, formData); + response = await UsersAPI.createToken(userId, { + ...formData, + application: formData.application?.id || null, + }); } else { - await TokensAPI.create(formData); + response = await TokensAPI.create(formData); } - history.push(`/users/${userId}/tokens`); + onSuccessfulAdd(response.data); + + history.push(`/users/${userId}/tokens/${response.data.id}/details`); }, - [history, userId] + [history, userId, onSuccessfulAdd] ) ); diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx index 4323663c45..b74d2712c1 100644 --- a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx @@ -18,29 +18,46 @@ jest.mock('react-router-dom', () => ({ })); let wrapper; +const onSuccessfulAdd = jest.fn(); + describe('', () => { + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); test('handleSubmit should post to api', async () => { await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } }); const tokenData = { - application: 1, + application: { + id: 1, + }, description: 'foo', scope: 'read', }; await act(async () => { wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); }); - expect(UsersAPI.createToken).toHaveBeenCalledWith(1, tokenData); + expect(UsersAPI.createToken).toHaveBeenCalledWith(1, { + application: 1, + description: 'foo', + scope: 'read', + }); }); test('should navigate to tokens list when cancel is clicked', async () => { const history = createMemoryHistory({}); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); }); await act(async () => { wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); @@ -48,51 +65,67 @@ describe('', () => { expect(history.location.pathname).toEqual('/users/1/tokens'); }); - test('successful form submission should trigger redirect', async () => { + test('successful form submission with application', async () => { const history = createMemoryHistory({}); const tokenData = { - application: 1, + application: { + id: 1, + }, description: 'foo', scope: 'read', }; + const rtnData = { + id: 2, + token: 'abc', + refresh_token: 'def', + expires: '3020-03-28T14:26:48.099297Z', + }; UsersAPI.createToken.mockResolvedValueOnce({ - data: { - id: 2, - ...tokenData, - }, + data: rtnData, }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); await act(async () => { wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); }); - expect(history.location.pathname).toEqual('/users/1/tokens'); + expect(history.location.pathname).toEqual('/users/1/tokens/2/details'); + expect(onSuccessfulAdd).toHaveBeenCalledWith(rtnData); }); - test('should successful submit form with application', async () => { + test('successful form submission without application', async () => { const history = createMemoryHistory({}); const tokenData = { scope: 'read', }; + const rtnData = { + id: 2, + token: 'abc', + refresh_token: null, + expires: '3020-03-28T14:26:48.099297Z', + }; TokensAPI.create.mockResolvedValueOnce({ - data: { - id: 2, - ...tokenData, - }, + data: rtnData, }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); await act(async () => { wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData); }); - expect(history.location.pathname).toEqual('/users/1/tokens'); + expect(history.location.pathname).toEqual('/users/1/tokens/2/details'); + expect(onSuccessfulAdd).toHaveBeenCalledWith(rtnData); }); }); diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx index 4e6891767d..9285d05b43 100644 --- a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx @@ -1,8 +1,7 @@ import React, { useCallback } from 'react'; -import { Link, useHistory, useParams } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; import AlertModal from '../../../components/AlertModal'; import { CardBody, CardActionsRow } from '../../../components/Card'; @@ -14,11 +13,19 @@ import { } from '../../../components/DetailList'; import ErrorDetail from '../../../components/ErrorDetail'; import { TokensAPI } from '../../../api'; +import { formatDateString } from '../../../util/dates'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { toTitleCase } from '../../../util/strings'; -function UserTokenDetail({ token, canEditOrDelete, i18n }) { - const { scope, description, created, modified, summary_fields } = token; +function UserTokenDetail({ token, i18n }) { + const { + scope, + description, + created, + modified, + expires, + summary_fields, + } = token; const history = useHistory(); const { id, tokenId } = useParams(); const { request: deleteToken, isLoading, error: deleteError } = useRequest( @@ -37,39 +44,43 @@ function UserTokenDetail({ token, canEditOrDelete, i18n }) { value={summary_fields?.application?.name} dataCy="application-token-detail-name" /> - - + + + - {canEditOrDelete && ( - <> - - - {i18n._(t`Delete`)} - - - )} + + {i18n._(t`Delete`)} + {error && ( ', () => { description: 'cdfsg', scope: 'read', }; - test('should call api for token details and actions', async () => { + test('should render properly', async () => { await act(async () => { - wrapper = mountWithContexts( - - ); + wrapper = mountWithContexts(); }); + expect(wrapper.find('UserTokenDetail').length).toBe(1); - }); - test('should call api for token details and actions', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); expect(wrapper.find('Detail[label="Application"]').prop('value')).toBe( 'hg' @@ -65,23 +57,11 @@ describe('', () => { expect( wrapper.find('UserDateDetail[label="Last Modified"]').prop('date') ).toBe('2020-06-23T19:56:38.441353Z'); - expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(1); expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1); }); - test('should not render edit or delete buttons', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0); - expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0); - }); test('should delete token properly', async () => { await act(async () => { - wrapper = mountWithContexts( - - ); + wrapper = mountWithContexts(); }); await act(async () => wrapper.find('Button[aria-label="Delete"]').prop('onClick')() @@ -90,7 +70,7 @@ describe('', () => { await act(async () => wrapper.find('DeleteButton').prop('onConfirm')()); expect(TokensAPI.destroy).toBeCalledWith(2); }); - test('should throw deletion error', async () => { + test('should display error on failed deletion', async () => { TokensAPI.destroy.mockRejectedValue( new Error({ response: { @@ -104,9 +84,7 @@ describe('', () => { }) ); await act(async () => { - wrapper = mountWithContexts( - - ); + wrapper = mountWithContexts(); }); await act(async () => wrapper.find('Button[aria-label="Delete"]').prop('onClick')() diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx index a60f4412b6..72ba8b31c5 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx @@ -108,7 +108,7 @@ function UserTokenList({ i18n }) { onRowClick={handleSelect} toolbarSearchColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`Application name`), key: 'application__name__icontains', isDefault: true, }, @@ -119,7 +119,7 @@ function UserTokenList({ i18n }) { ]} toolbarSortColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`Application name`), key: 'application__name', }, { diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx index 52eb44a7f1..6ae8325723 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -36,28 +36,33 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { /> + + {token.summary_fields?.application + ? i18n._(t`Application access token`) + : i18n._(t`Personal access token`)} + + , - {token.summary_fields?.application?.name ? ( + {token.summary_fields?.application && ( {i18n._(t`Application`)} - + {token.summary_fields.application.name} - ) : ( - - {i18n._(t`Personal access token`)} - )} , - + {toTitleCase(token.scope)} , - + {formatDateString(token.expires)} , diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx index a91e2d1632..87bc06401c 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx @@ -21,7 +21,7 @@ const token = { }, application: { id: 1, - name: 'app', + name: 'Foobar app', }, }, created: '2020-06-23T15:06:43.188634Z', @@ -44,22 +44,57 @@ describe('', () => { expect(wrapper.find('UserTokenListItem').length).toBe(1); }); - test('should render proper data', async () => { + test('should render application access token row properly', async () => { await act(async () => { wrapper = mountWithContexts( ); }); expect(wrapper.find('DataListCheck').prop('checked')).toBe(false); + expect(wrapper.find('PFDataListCell[aria-label="Token type"]').text()).toBe( + 'Application access token' + ); expect( - wrapper.find('PFDataListCell[aria-label="application name"]').text() - ).toBe('Applicationapp'); - expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe( - 'ScopeRead' + wrapper.find('PFDataListCell[aria-label="Application name"]').text() + ).toContain('Foobar app'); + expect(wrapper.find('PFDataListCell[aria-label="Scope"]').text()).toContain( + 'Read' ); - expect(wrapper.find('PFDataListCell[aria-label="expiration"]').text()).toBe( - 'Expires10/25/3019, 3:06:43 PM' + expect( + wrapper.find('PFDataListCell[aria-label="Expiration"]').text() + ).toContain('10/25/3019, 3:06:43 PM'); + }); + + test('should render personal access token row properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('DataListCheck').prop('checked')).toBe(false); + expect(wrapper.find('PFDataListCell[aria-label="Token type"]').text()).toBe( + 'Personal access token' ); + expect( + wrapper.find('PFDataListCell[aria-label="Application name"]').text() + ).toBe(''); + expect(wrapper.find('PFDataListCell[aria-label="Scope"]').text()).toContain( + 'Write' + ); + expect( + wrapper.find('PFDataListCell[aria-label="Expiration"]').text() + ).toContain('10/25/3019, 3:06:43 PM'); }); test('should be checked', async () => { diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx index c73519d7f9..450bd41822 100644 --- a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -1,24 +1,99 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; import { Switch, Route, useParams } from 'react-router-dom'; +import { + Alert, + ClipboardCopy, + ClipboardCopyVariant, + Modal, +} from '@patternfly/react-core'; +import { formatDateString } from '../../../util/dates'; +import { Detail, DetailList } from '../../../components/DetailList'; import UserTokenAdd from '../UserTokenAdd'; import UserTokenList from '../UserTokenList'; import UserToken from '../UserToken'; -function UserTokens({ setBreadcrumb, user }) { +const TokenAlert = styled(Alert)` + margin-bottom: 20px; +`; + +function UserTokens({ i18n, setBreadcrumb, user }) { + const [tokenModalSource, setTokenModalSource] = useState(null); const { id } = useParams(); + + const onSuccessfulAdd = useCallback(token => setTokenModalSource(token), [ + setTokenModalSource, + ]); + return ( - - - - - - - - - - - + <> + + + + + + + + + + + + {tokenModalSource && ( + setTokenModalSource(null)} + > + + + {tokenModalSource.token && ( + + {tokenModalSource.token} + + } + /> + )} + {tokenModalSource.refresh_token && ( + + {tokenModalSource.refresh_token} + + } + /> + )} + + + + )} + ); } diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.test.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.test.jsx new file mode 100644 index 0000000000..7a66eadca7 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.test.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import UserTokens from './UserTokens'; + +describe('', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + test('renders successfully', () => { + wrapper = mountWithContexts(); + expect(wrapper.length).toBe(1); + }); + + test('shows Application information modal after successful creation', async () => { + const history = createMemoryHistory({ + initialEntries: ['/users/1/tokens/add'], + }); + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + expect(wrapper.find('Modal[title="Token information"]').length).toBe(0); + await act(async () => { + wrapper + .find('UserTokenAdd') + .props() + .onSuccessfulAdd({ + expires: '3020-03-28T14:26:48.099297Z', + token: 'foobar', + refresh_token: 'aaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + }); + wrapper.update(); + expect(wrapper.find('Modal[title="Token information"]').length).toBe(1); + }); +});