From a9d4046ec5f0730dbf2488d021d238bb3f56e42c Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 30 Jun 2020 17:55:14 -0400 Subject: [PATCH] Adds Application Token List with delete functionality --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Applications.js | 6 + awx/ui_next/src/api/models/Tokens.js | 10 + .../Application/Application/Application.jsx | 10 +- .../ApplicationTokenList.jsx | 163 +++++++++++++++ .../ApplicationTokenList.test.jsx | 193 ++++++++++++++++++ .../ApplicationTokenListItem.jsx | 69 +++++++ .../ApplicationTokenListItem.test.jsx | 90 ++++++++ .../Application/ApplicationTokens/index.js | 1 + awx/ui_next/src/types.js | 7 + 10 files changed, 547 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/api/models/Tokens.js create mode 100644 awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Application/ApplicationTokens/index.js diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 2de6a235e0..c3cfc1167f 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -24,6 +24,7 @@ import Roles from './models/Roles'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; +import Tokens from './models/Tokens'; import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import UnifiedJobs from './models/UnifiedJobs'; import Users from './models/Users'; @@ -58,6 +59,7 @@ const RolesAPI = new Roles(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); +const TokensAPI = new Tokens(); const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UnifiedJobsAPI = new UnifiedJobs(); const UsersAPI = new Users(); @@ -93,6 +95,7 @@ export { SchedulesAPI, SystemJobsAPI, TeamsAPI, + TokensAPI, UnifiedJobTemplatesAPI, UnifiedJobsAPI, UsersAPI, diff --git a/awx/ui_next/src/api/models/Applications.js b/awx/ui_next/src/api/models/Applications.js index 50b709bdca..51aaeaa2a1 100644 --- a/awx/ui_next/src/api/models/Applications.js +++ b/awx/ui_next/src/api/models/Applications.js @@ -5,6 +5,12 @@ class Applications extends Base { super(http); this.baseUrl = '/api/v2/applications/'; } + + readTokens(appId, params) { + return this.http.get(`${this.baseUrl}${appId}/tokens/`, { + params, + }); + } } export default Applications; diff --git a/awx/ui_next/src/api/models/Tokens.js b/awx/ui_next/src/api/models/Tokens.js new file mode 100644 index 0000000000..5dd490808d --- /dev/null +++ b/awx/ui_next/src/api/models/Tokens.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Tokens extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/tokens/'; + } +} + +export default Tokens; diff --git a/awx/ui_next/src/screens/Application/Application/Application.jsx b/awx/ui_next/src/screens/Application/Application/Application.jsx index dabce2167f..8764f7d2ca 100644 --- a/awx/ui_next/src/screens/Application/Application/Application.jsx +++ b/awx/ui_next/src/screens/Application/Application/Application.jsx @@ -15,9 +15,9 @@ 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 ApplicationTokens from '../ApplicationTokens'; import RoutedTabs from '../../../components/RoutedTabs'; function Application({ setBreadcrumb, i18n }) { @@ -82,6 +82,7 @@ function Application({ setBreadcrumb, i18n }) { if (pathname.endsWith('edit')) { cardHeader = null; } + if (!isLoading && error) { return ( @@ -101,10 +102,6 @@ function Application({ setBreadcrumb, i18n }) { ); } - if (isLoading) { - return ; - } - return ( @@ -131,6 +128,9 @@ function Application({ setBreadcrumb, i18n }) { clientTypeOptions={clientTypeOptions} /> + + + )} diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx new file mode 100644 index 0000000000..453a387286 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import PaginatedDataList, { + ToolbarDeleteButton, +} from '../../../components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { TokensAPI, ApplicationsAPI } from '../../../api'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import ApplicationTokenListItem from './ApplicationTokenListItem'; +import DatalistToolbar from '../../../components/DataListToolbar'; + +const QS_CONFIG = getQSConfig('applications', { + page: 1, + page_size: 20, + order_by: 'user__username', +}); + +function ApplicationTokenList({ i18n }) { + const { id } = useParams(); + const location = useLocation(); + const { + error, + isLoading, + result: { tokens, itemCount }, + request: fetchTokens, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { + data: { results, count }, + } = await ApplicationsAPI.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.name = 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 { + isLoading: deleteLoading, + deletionError, + deleteItems: handleDeleteApplications, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id: tokenId }) => TokensAPI.destroy(tokenId)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchTokens, + } + ); + + const handleDelete = async () => { + await handleDeleteApplications(); + setSelected([]); + }; + return ( + <> + ( + + setSelected(isSelected ? [...tokens] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + , + ]} + /> + )} + renderItem={token => ( + handleSelect(token)} + isSelected={selected.some(row => row.id === token.id)} + /> + )} + /> + + {i18n._(t`Failed to delete one or more tokens.`)} + + + + ); +} + +export default withI18n()(ApplicationTokenList); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx new file mode 100644 index 0000000000..d4f44824fb --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ApplicationsAPI, TokensAPI } from '../../../api'; +import ApplicationTokenList from './ApplicationTokenList'; + +jest.mock('../../../api/models/Applications'); +jest.mock('../../../api/models/Tokens'); + +const tokens = { + data: { + results: [ + { + 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: 2, + }, +}; +describe('', () => { + let wrapper; + test('should mount properly', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + }); + test('should have data fetched and render 2 rows', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ApplicationTokenListItem').length).toBe(2); + expect(ApplicationsAPI.readTokens).toBeCalled(); + }); + + test('should delete item successfully', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + + wrapper + .find('input#select-token-2') + .simulate('change', tokens.data.results[0]); + + wrapper.update(); + + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + expect(TokensAPI.destroy).toBeCalledWith(tokens.data.results[0].id); + }); + + test('should throw content error', async () => { + ApplicationsAPI.readTokens.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/applications/', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + + await waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + TokensAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/tokens/', + }, + data: 'An error occurred', + }, + }) + ); + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + + wrapper.find('input#select-token-2').simulate('change', 'a'); + + wrapper.update(); + + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('should not render add button', async () => { + ApplicationsAPI.readTokens.mockResolvedValue(tokens); + + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ApplicationTokenList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx new file mode 100644 index 0000000000..142561ea7e --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { Token } from '../../../types'; +import { formatDateString } from '../../../util/dates'; +import { toTitleCase } from '../../../util/strings'; +import DataListCell from '../../../components/DataListCell'; + +const Label = styled.b` + margin-right: 20px; +`; + +function ApplicationTokenListItem({ + token, + isSelected, + onSelect, + detailUrl, + i18n, +}) { + const labelId = `check-action-${token.id}`; + return ( + + + + + + {token.summary_fields.user.username} + + , + + + {toTitleCase(token.scope)} + , + + + {formatDateString(token.expires)} + , + ]} + /> + + + ); +} + +ApplicationTokenListItem.propTypes = { + token: Token.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(ApplicationTokenListItem); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx new file mode 100644 index 0000000000..94d0355951 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenListItem.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import ApplicationTokenListItem from './ApplicationTokenListItem'; + +describe('', () => { + let wrapper; + const token = { + 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', + }; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('ApplicationTokenListItem').length).toBe(1); + }); + test('should render the proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('DataListCell[aria-label="token name"]').text()).toBe( + 'admin' + ); + expect(wrapper.find('DataListCell[aria-label="scope"]').text()).toBe( + 'ScopeRead' + ); + expect(wrapper.find('DataListCell[aria-label="expiration"]').text()).toBe( + 'Expiration10/25/3019, 7:56:38 PM' + ); + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(false); + }); + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#select-token-2').prop('checked')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/index.js b/awx/ui_next/src/screens/Application/ApplicationTokens/index.js new file mode 100644 index 0000000000..34dd462061 --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/index.js @@ -0,0 +1 @@ +export { default } from './ApplicationTokenList'; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 4ba0808d7d..7a66ae3c68 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -234,6 +234,13 @@ export const Team = shape({ organization: number, }); +export const Token = shape({ + id: number.isRequired, + expires: string.isRequired, + summary_fields: shape({}), + scope: string.isRequired, +}); + export const User = shape({ id: number.isRequired, type: oneOf(['user']),