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/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..53e8d1c742 100644 --- a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx @@ -14,11 +14,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; + const { + scope, + description, + created, + modified, + expires, + summary_fields, + } = token; const history = useHistory(); const { id, tokenId } = useParams(); const { request: deleteToken, isLoading, error: deleteError } = useRequest( @@ -39,6 +47,7 @@ function UserTokenDetail({ token, canEditOrDelete, i18n }) { /> + + + {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..21a9f486a8 100644 --- a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -1,24 +1,86 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import { Switch, Route, useParams } from 'react-router-dom'; +import { + 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 }) { +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); + }); +});