From 19d6a3f65e63c1a688d6ed02d99d6924cbb0745f Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 3 Aug 2020 13:06:26 -0400 Subject: [PATCH 1/2] Adds User Token Details page --- awx/ui_next/src/screens/User/User.jsx | 10 +- .../src/screens/User/UserToken/UserToken.jsx | 117 +++++++++++++++++ .../screens/User/UserToken/UserToken.test.jsx | 101 +++++++++++++++ .../src/screens/User/UserToken/index.js | 1 + .../User/UserTokenDetail/UserTokenDetail.jsx | 81 ++++++++++++ .../UserTokenDetail/UserTokenDetail.test.jsx | 120 ++++++++++++++++++ .../src/screens/User/UserTokenDetail/index.js | 1 + .../User/UserTokenList/UserTokenListItem.jsx | 6 +- .../screens/User/UserTokens/UserTokens.jsx | 13 +- awx/ui_next/src/screens/User/Users.jsx | 6 +- 10 files changed, 447 insertions(+), 9 deletions(-) create mode 100644 awx/ui_next/src/screens/User/UserToken/UserToken.jsx create mode 100644 awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserToken/index.js create mode 100644 awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenDetail/index.js diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index 95dcb487b4..d071cedd8f 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -80,7 +80,9 @@ function User({ i18n, setBreadcrumb, me }) { } let showCardHeader = true; - if (['edit', 'add'].some(name => location.pathname.includes(name))) { + if ( + ['edit', 'add', 'tokens'].some(name => location.pathname.includes(name)) + ) { showCardHeader = false; } @@ -131,7 +133,11 @@ function User({ i18n, setBreadcrumb, me }) { )} - + diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx new file mode 100644 index 0000000000..af6f0f0b16 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; +import RoutedTabs from '../../../components/RoutedTabs'; +import ContentError from '../../../components/ContentError'; +import { TokensAPI } from '../../../api'; +import useRequest from '../../../util/useRequest'; +import UserTokenDetail from '../UserTokenDetail'; + +function UserToken({ i18n, setBreadcrumb, user }) { + const location = useLocation(); + const { id, tokenId } = useParams(); + const { + isLoading, + error, + request: fetchToken, + result: { token, actions }, + } = useRequest( + useCallback(async () => { + const [response, actionsResponse] = await Promise.all([ + TokensAPI.readDetail(tokenId), + TokensAPI.readOptions(), + ]); + setBreadcrumb(user, response.data); + return { + token: response.data, + actions: actionsResponse.data.actions.POST, + }; + }, [setBreadcrumb, user, tokenId]), + { token: null, actions: null } + ); + useEffect(() => { + fetchToken(); + }, [fetchToken]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Tokens`)} + + ), + link: `/users/${id}/tokens`, + id: 99, + }, + { + name: i18n._(t`Details`), + link: `/users/${id}/tokens/${tokenId}/details`, + id: 0, + }, + ]; + + let showCardHeader = true; + + if (location.pathname.endsWith('edit')) { + showCardHeader = false; + } + + if (!isLoading && error) { + return ( + + + + {error.response.status === 404 && ( + + {i18n._(t`Token not found.`)}{' '} + + {i18n._(t`View all tokens.`)} + + + )} + + + + ); + } + + return ( + <> + {showCardHeader && } + + + {token && ( + + + + )} + + {!isLoading && ( + + {id && ( + {i18n._(t`View Tokens`)} + )} + + )} + + + + ); +} + +export default withI18n()(UserToken); diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx new file mode 100644 index 0000000000..8e71f1b085 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { TokensAPI } from '../../../api'; +import UserToken from './UserToken'; + +jest.mock('../../../api/models/Tokens'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + tokenId: 2, + }), +})); +describe('', () => { + let wrapper; + const user = { + id: 1, + type: 'user', + url: '/api/v2/users/1/', + summary_fields: { + user_capabilities: { + edit: true, + delete: false, + }, + }, + created: '2020-06-19T12:55:13.138692Z', + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + email: 'a@g.com', + }; + test('should call api for token details and actions', async () => { + TokensAPI.readDetail.mockResolvedValue({ + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + scope: 'read', + }); + TokensAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('UserToken').length).toBe(1); + }); + test('should call api for token details and actions', async () => { + TokensAPI.readDetail.mockResolvedValue({ + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + 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/UserToken/index.js b/awx/ui_next/src/screens/User/UserToken/index.js new file mode 100644 index 0000000000..f899410e7d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserToken/index.js @@ -0,0 +1 @@ +export { default } from './UserToken'; diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx new file mode 100644 index 0000000000..37eeb63416 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx @@ -0,0 +1,81 @@ +import React, { useCallback } from 'react'; +import { Link, 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'; +import DeleteButton from '../../../components/DeleteButton'; +import { DetailList, Detail } from '../../../components/DetailList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import { formatDateString } from '../../../util/dates'; +import { TokensAPI } from '../../../api'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; + +function UserTokenDetail({ token, canEditOrDelete, i18n }) { + const { scope, description, created, modified, summary_fields } = token; + const history = useHistory(); + const { id, tokenId } = useParams(); + const { request: deleteTeam, isLoading, error: deleteError } = useRequest( + useCallback(async () => { + await TokensAPI.destroy(tokenId); + history.push(`/users/${id}/tokens`); + }, [tokenId, id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + return ( + + + + + + + + + + {canEditOrDelete && ( + <> + + + {i18n._(t`Delete`)} + + + )} + + {error && ( + + {i18n._(t`Failed to user token.`)} + + + )} + + ); +} + +export default withI18n()(UserTokenDetail); diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx new file mode 100644 index 0000000000..6b0d382e0c --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { TokensAPI } from '../../../api'; +import UserTokenDetail from './UserTokenDetail'; + +jest.mock('../../../api/models/Tokens'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + tokenId: 2, + }), +})); +describe('', () => { + let wrapper; + const token = { + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: 'Alex', + last_name: 'Corey', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + scope: 'read', + }; + test('should call api for token details and actions', async () => { + await act(async () => { + 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' + ); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( + 'cdfsg' + ); + expect(wrapper.find('Detail[label="Scope"]').prop('value')).toBe('read'); + expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe( + '6/23/2020, 7:56:38 PM' + ); + expect(wrapper.find('Detail[label="Last Modified"]').prop('value')).toBe( + '6/23/2020, 7:56:38 PM' + ); + 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( + + ); + }); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => wrapper.find('DeleteButton').prop('onConfirm')()); + expect(TokensAPI.destroy).toBeCalledWith(2); + }); + test('should throw deletion error', async () => { + TokensAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/tokens', + }, + data: 'An error occurred', + status: 400, + }, + }) + ); + 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(TokensAPI.destroy).toBeCalledWith(2); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/index.js b/awx/ui_next/src/screens/User/UserTokenDetail/index.js new file mode 100644 index 0000000000..a6a9011996 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenDetail/index.js @@ -0,0 +1 @@ +export { default } from './UserTokenDetail'; diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx index 4b1198c5a9..7fabcb878c 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { @@ -22,6 +23,7 @@ const NameLabel = styled.b` `; function UserTokenListItem({ i18n, token, isSelected, onSelect }) { + const { id } = useParams(); const labelId = `check-action-${token.id}`; return ( @@ -41,7 +43,9 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { {token.summary_fields?.application?.name ? ( {i18n._(t`Application`)} - {token.summary_fields.application.name} + + {token.summary_fields.application.name} + ) : ( i18n._(t`Personal access token`) diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx index dc072a6546..c73519d7f9 100644 --- a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -3,17 +3,20 @@ import { withI18n } from '@lingui/react'; import { Switch, Route, useParams } from 'react-router-dom'; import UserTokenAdd from '../UserTokenAdd'; import UserTokenList from '../UserTokenList'; +import UserToken from '../UserToken'; -function UserTokens() { - const { id: userId } = useParams(); - +function UserTokens({ setBreadcrumb, user }) { + const { id } = useParams(); return ( - + + + + - + ); diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index 6f21f8be10..e9fe2d4ef2 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -18,7 +18,7 @@ function Users({ i18n }) { const match = useRouteMatch(); const addUserBreadcrumb = useCallback( - user => { + (user, token) => { if (!user) { return; } @@ -34,6 +34,10 @@ function Users({ i18n }) { [`/users/${user.id}/organizations`]: i18n._(t`Organizations`), [`/users/${user.id}/tokens`]: i18n._(t`Tokens`), [`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`), + [`/users/${user.id}/tokens/${token && token.id}`]: `Application Name`, + [`/users/${user.id}/tokens/${token && token.id}/details`]: i18n._( + t`Details` + ), }); }, [i18n] From 15fda43a1007ef9aff006fe3f00fedc2d9c7a52f Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 4 Aug 2020 14:29:37 -0400 Subject: [PATCH 2/2] Utilizes UserDateDetail, Capitalizes Scope value, fixes spelling errors --- .../components/Lookup/ApplicationLookup.jsx | 2 +- .../User/UserTokenDetail/UserTokenDetail.jsx | 28 ++++++++++++------- .../UserTokenDetail/UserTokenDetail.test.jsx | 12 ++++---- .../User/UserTokenList/UserTokenListItem.jsx | 8 ++++-- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx index 01fd154c47..ca5871c2cc 100644 --- a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx @@ -44,7 +44,7 @@ function ApplicationLookup({ i18n, onChange, value, label }) { actionsResponse?.data?.related_search_fields || [] ).map(val => val.slice(0, -8)), searchableKeys: Object.keys( - actionsResponse.data.actions?.GET || {} + actionsResponse?.data?.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), }; }, [location]), diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx index 37eeb63416..4e6891767d 100644 --- a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx @@ -7,23 +7,26 @@ import { Button } from '@patternfly/react-core'; import AlertModal from '../../../components/AlertModal'; import { CardBody, CardActionsRow } from '../../../components/Card'; import DeleteButton from '../../../components/DeleteButton'; -import { DetailList, Detail } from '../../../components/DetailList'; +import { + DetailList, + Detail, + UserDateDetail, +} from '../../../components/DetailList'; import ErrorDetail from '../../../components/ErrorDetail'; -import { formatDateString } from '../../../util/dates'; import { TokensAPI } from '../../../api'; 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 history = useHistory(); const { id, tokenId } = useParams(); - const { request: deleteTeam, isLoading, error: deleteError } = useRequest( + const { request: deleteToken, isLoading, error: deleteError } = useRequest( useCallback(async () => { await TokensAPI.destroy(tokenId); history.push(`/users/${id}/tokens`); }, [tokenId, id, history]) ); - const { error, dismissError } = useDismissableError(deleteError); return ( @@ -32,14 +35,19 @@ function UserTokenDetail({ token, canEditOrDelete, i18n }) { - - - + + @@ -55,7 +63,7 @@ function UserTokenDetail({ token, canEditOrDelete, i18n }) { {i18n._(t`Delete`)} diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx index 6b0d382e0c..a3462f758f 100644 --- a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx @@ -58,13 +58,13 @@ describe('', () => { expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( 'cdfsg' ); - expect(wrapper.find('Detail[label="Scope"]').prop('value')).toBe('read'); - expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe( - '6/23/2020, 7:56:38 PM' - ); - expect(wrapper.find('Detail[label="Last Modified"]').prop('value')).toBe( - '6/23/2020, 7:56:38 PM' + expect(wrapper.find('Detail[label="Scope"]').prop('value')).toBe('Read'); + expect(wrapper.find('UserDateDetail[label="Created"]').prop('date')).toBe( + '2020-06-23T19:56:38.422053Z' ); + 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); }); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx index 7fabcb878c..52eb44a7f1 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -11,7 +11,7 @@ import { import styled from 'styled-components'; import { toTitleCase } from '../../../util/strings'; -import { formatDateStringUTC } from '../../../util/dates'; +import { formatDateString } from '../../../util/dates'; import DataListCell from '../../../components/DataListCell'; const Label = styled.b` @@ -48,7 +48,9 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { ) : ( - i18n._(t`Personal access token`) + + {i18n._(t`Personal access token`)} + )} , @@ -57,7 +59,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { , - {formatDateStringUTC(token.expires)} + {formatDateString(token.expires)} , ]} />