From 73bb539b16273a321711f9420e0aef2255692dc6 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 24 Jun 2020 13:03:02 -0400 Subject: [PATCH] Adds Tokens List --- awx/ui_next/src/api/models/Users.js | 6 + awx/ui_next/src/screens/User/User.jsx | 16 +- awx/ui_next/src/screens/User/User.test.jsx | 57 ++++-- .../User/UserTokenList/UserTokenList.jsx | 146 +++++++++++++++ .../User/UserTokenList/UserTokenList.test.jsx | 172 ++++++++++++++++++ .../User/UserTokenList/UserTokenListItem.jsx | 52 ++++++ .../UserTokenList/UserTokenListItem.test.jsx | 73 ++++++++ .../src/screens/User/UserTokenList/index.js | 1 + .../screens/User/UserTokens/UserTokens.jsx | 10 - .../src/screens/User/UserTokens/index.js | 1 - awx/ui_next/src/screens/User/Users.jsx | 7 +- 11 files changed, 513 insertions(+), 28 deletions(-) create mode 100644 awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserTokenList/index.js delete mode 100644 awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx delete mode 100644 awx/ui_next/src/screens/User/UserTokens/index.js diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index 12eb74c4a6..3d4ec4aac9 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -44,6 +44,12 @@ class Users extends Base { readTeamsOptions(userId) { return this.http.options(`${this.baseUrl}${userId}/teams/`); } + + readTokens(userId, params) { + return this.http.get(`${this.baseUrl}${userId}/tokens/`, { + params, + }); + } } export default Users; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index 1aa8bca032..0ddf42f9fc 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -20,10 +20,10 @@ import UserDetail from './UserDetail'; import UserEdit from './UserEdit'; import UserOrganizations from './UserOrganizations'; import UserTeams from './UserTeams'; -import UserTokens from './UserTokens'; +import UserTokenList from './UserTokenList'; import UserAccessList from './UserAccess/UserAccessList'; -function User({ i18n, setBreadcrumb }) { +function User({ i18n, setBreadcrumb, me }) { const location = useLocation(); const match = useRouteMatch('/users/:id'); const userListUrl = `/users`; @@ -69,9 +69,16 @@ function User({ i18n, setBreadcrumb }) { }, { name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 }, { name: i18n._(t`Access`), link: `${match.url}/access`, id: 3 }, - { name: i18n._(t`Tokens`), link: `${match.url}/tokens`, id: 4 }, ]; + if (me?.id === Number(match.params.id)) { + tabsArray.push({ + name: i18n._(t`Tokens`), + link: `${match.url}/tokens`, + id: 4, + }); + } + let showCardHeader = true; if (['edit'].some(name => location.pathname.includes(name))) { showCardHeader = false; @@ -93,6 +100,7 @@ function User({ i18n, setBreadcrumb }) { ); } + return ( @@ -123,7 +131,7 @@ function User({ i18n, setBreadcrumb }) { )} - + diff --git a/awx/ui_next/src/screens/User/User.test.jsx b/awx/ui_next/src/screens/User/User.test.jsx index ae3d951ceb..5765aa2158 100644 --- a/awx/ui_next/src/screens/User/User.test.jsx +++ b/awx/ui_next/src/screens/User/User.test.jsx @@ -56,21 +56,24 @@ describe('', () => { }); let wrapper; await act(async () => { - wrapper = mountWithContexts( {}} />, { - context: { - router: { - history, - route: { - location: history.location, - match: { - params: { id: 1 }, - url: '/users/1', - path: '/users/1', + wrapper = mountWithContexts( + {}} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/users/1', + path: '/users/1', + }, }, }, }, - }, - }); + } + ); }); await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 6); @@ -78,6 +81,36 @@ 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); + const history = createMemoryHistory({ + initialEntries: ['/users/1'], + }); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/users/1', + path: '/users/1', + }, + }, + }, + }, + } + ); + }); + expect(wrapper.find('button[aria-label="Tokens"]').length).toBe(0); + }); + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/users/1/foobar'], diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx new file mode 100644 index 0000000000..315c035f4b --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx @@ -0,0 +1,146 @@ +import React, { useCallback, useEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import PaginatedDataList, { + ToolbarAddButton, + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import useSelected from '../../../util/useSelected'; +import useRequest from '../../../util/useRequest'; +import { UsersAPI } from '../../../api'; +import DataListToolbar from '../../../components/DataListToolbar'; +import UserTokensListItem from './UserTokenListItem'; + +const QS_CONFIG = getQSConfig('user', { + page: 1, + page_size: 20, + order_by: 'application__name', +}); +function UserTokenList({ i18n }) { + const location = useLocation(); + const { id } = useParams(); + + const { + error, + isLoading, + request: fetchTokens, + result: { tokens, itemCount }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { + data: { results, count }, + } = await UsersAPI.readTokens(id, params); + const modifiedResults = results.map(result => { + result.summary_fields = { + user: result.summary_fields.user, + application: result.summary_fields.application, + user_capabilities: { delete: true }, + }; + result.username = result.summary_fields.user.username; + return result; + }); + return { tokens: modifiedResults, itemCount: count }; + }, [id, location.search]), + { tokens: [], itemCount: 0 } + ); + + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + tokens + ); + + const canAdd = true; + return ( + ( + setSelected(isSelected ? [...tokens] : [])} + additionalControls={[ + ...(canAdd + ? [ + , + ] + : []), + {}} + itemsToDelete={selected} + pluralizedItemName="Tokens" + />, + ]} + /> + )} + renderItem={token => ( + { + handleSelect(token); + }} + detailUrl={`${location.pathname}/details`} + isSelected={selected.some(row => row.id === token.id)} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + ); +} + +export default withI18n()(UserTokenList); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx new file mode 100644 index 0000000000..faf233b9bd --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.test.jsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { UsersAPI } from '../../../api'; +import UserTokenList from './UserTokenList'; + +jest.mock('../../../api/models/Users'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + search: '', + }), + useParams: () => ({ + id: 1, + }), +})); + +const tokens = { + data: { + results: [ + { + id: 1, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/1/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/1/', + activity_stream: '/api/v2/tokens/1/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 1, + name: 'app', + }, + }, + created: '2020-06-23T15:06:43.188634Z', + modified: '2020-06-23T15:06:43.224151Z', + description: '', + user: 1, + token: '************', + refresh_token: '************', + application: 1, + expires: '3019-10-25T15:06:43.182788Z', + scope: 'read', + }, + { + id: 2, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/2/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/3/', + activity_stream: '/api/v2/tokens/2/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:38.422053Z', + modified: '2020-06-23T19:56:38.441353Z', + description: 'cdfsg', + user: 1, + token: '************', + refresh_token: '************', + application: 3, + expires: '3019-10-25T19:56:38.395635Z', + scope: 'read', + }, + { + id: 3, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/3/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/3/', + activity_stream: '/api/v2/tokens/3/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 3, + name: 'hg', + }, + }, + created: '2020-06-23T19:56:50.536169Z', + modified: '2020-06-23T19:56:50.549521Z', + description: 'fgds', + user: 1, + token: '************', + refresh_token: '************', + application: 3, + expires: '3019-10-25T19:56:50.529306Z', + scope: 'write', + }, + ], + count: 3, + }, +}; + +describe('', () => { + let wrapper; + test('should mount properly, and fetch tokens', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(UsersAPI.readTokens).toHaveBeenCalledWith(1, { + order_by: 'application__name', + page: 1, + page_size: 20, + }); + expect(wrapper.find('UserTokenList').length).toBe(1); + }); + + test('edit button should be disabled', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('should enable edit button', async () => { + UsersAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect( + wrapper.find('DataListCheck[id="select-token-3"]').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('DataListCheck[id="select-token-3"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-token-3"]').props().checked + ).toBe(true); + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + false + ); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx new file mode 100644 index 0000000000..6af8bd3e87 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { + DataListItemCells, + DataListCheck, + DataListItemRow, + DataListItem, +} from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { formatDateStringUTC } from '../../../util/dates'; +import DataListCell from '../../../components/DataListCell'; + +const Label = styled.b` + margin-right: 20px; +`; +function UserTokenListItem({ i18n, token, isSelected, detailUrl, onSelect }) { + const labelId = `check-action-${token.id}`; + return ( + + + + + + {token.summary_fields.application.name} + + , + + + {token.scope} + , + + + {formatDateStringUTC(token.expires)} + , + ]} + /> + + + ); +} + +export default withI18n()(UserTokenListItem); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx new file mode 100644 index 0000000000..cfefda696e --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import UserTokenListItem from './UserTokenListItem'; + +const token = { + id: 1, + type: 'o_auth2_access_token', + url: '/api/v2/tokens/1/', + related: { + user: '/api/v2/users/1/', + application: '/api/v2/applications/1/', + activity_stream: '/api/v2/tokens/1/activity_stream/', + }, + summary_fields: { + user: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + application: { + id: 1, + name: 'app', + }, + }, + created: '2020-06-23T15:06:43.188634Z', + modified: '2020-06-23T15:06:43.224151Z', + description: '', + user: 1, + token: '************', + refresh_token: '************', + application: 1, + expires: '3019-10-25T15:06:43.182788Z', + scope: 'read', +}; + +describe('', () => { + let wrapper; + test('should mount properly', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.find('UserTokenListItem').length).toBe(1); + }); + + test('should render proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('DataListCheck').prop('checked')).toBe(false); + expect(wrapper.find('PFDataListCell[aria-label="name"]').text()).toBe( + 'app' + ); + expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe( + 'Scoperead' + ); + expect(wrapper.find('PFDataListCell[aria-label="expiration"]').text()).toBe( + 'Expires10/25/3019, 3:06:43 PM' + ); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('DataListCheck').prop('checked')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenList/index.js b/awx/ui_next/src/screens/User/UserTokenList/index.js new file mode 100644 index 0000000000..3366fed23a --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenList/index.js @@ -0,0 +1 @@ +export { default } from './UserTokenList'; diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx deleted file mode 100644 index 5d342e00f2..0000000000 --- a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React, { Component } from 'react'; -import { CardBody } from '../../../components/Card'; - -class UserAdd extends Component { - render() { - return Coming soon :); - } -} - -export default UserAdd; diff --git a/awx/ui_next/src/screens/User/UserTokens/index.js b/awx/ui_next/src/screens/User/UserTokens/index.js deleted file mode 100644 index 8ea0743daa..0000000000 --- a/awx/ui_next/src/screens/User/UserTokens/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './UserTokens'; diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index b07e076f0e..575b997f48 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; +import { Config } from '../../contexts/Config'; import UsersList from './UserList/UserList'; import UserAdd from './UserAdd/UserAdd'; @@ -44,7 +45,11 @@ function Users({ i18n }) { - + + {({ me }) => ( + + )} +