From 5e5aba59b05c54f020c6d4679511b9dabd72b37a Mon Sep 17 00:00:00 2001 From: nixocio Date: Thu, 11 Jun 2020 11:37:32 -0400 Subject: [PATCH] Add Credential Type List and Delete Add `Credential Type` List and Delete features. See: https://github.com/ansible/awx/issues/7324 Also:https://github.com/ansible/awx/issues/7327 --- .../ApplicationsList/ApplicationList.test.jsx | 2 +- .../CredentialTypeList/CredentialTypeList.jsx | 165 +++++++++++++++++- .../CredentialTypeList.test.jsx | 165 ++++++++++++++++++ .../CredentialTypeListItem.jsx | 92 ++++++++++ .../CredentialTypeListItem.test.jsx | 99 +++++++++++ 5 files changed, 514 insertions(+), 9 deletions(-) create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx index 367886f2a9..7824ea5ed5 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx @@ -21,7 +21,7 @@ const applications = { user_capabilities: { edit: true, delete: true }, }, url: '', - organiation: 10, + organization: 10, }, { id: 2, diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index af9c5c0f11..ec807a5721 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -1,14 +1,163 @@ -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; -function CredentialTypeList() { +import { CredentialTypesAPI } from '../../../api'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import PaginatedDataList, { + ToolbarDeleteButton, + ToolbarAddButton, +} from '../../../components/PaginatedDataList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; +import DatalistToolbar from '../../../components/DataListToolbar'; +import CredentialTypeListItem from './CredentialTypeListItem'; + +const QS_CONFIG = getQSConfig('credential_type', { + page: 1, + page_size: 20, + order_by: 'name', + managed_by_tower: false, +}); + +function CredentialTypeList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const { + error: contentError, + isLoading, + request: fetchCredentialTypes, + result: { credentialTypes, credentialTypesCount, actions }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const [response, responseActions] = await Promise.all([ + CredentialTypesAPI.read(params), + CredentialTypesAPI.readOptions(), + ]); + + return { + credentialTypes: response.data.results, + credentialTypesCount: response.data.count, + actions: responseActions.data.actions, + }; + }, [location]), + { + credentialTypes: [], + credentialTypesCount: 0, + actions: {}, + } + ); + + useEffect(() => { + fetchCredentialTypes(); + }, [fetchCredentialTypes]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + credentialTypes + ); + + const { + isLoading: deleteLoading, + deletionError, + deleteItems: handleDeleteCredentialTypes, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id }) => CredentialTypesAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchCredentialTypes, + } + ); + + const handleDelete = async () => { + await handleDeleteCredentialTypes(); + setSelected([]); + }; + + const canAdd = actions && actions.POST; + return ( - - -
Credential Type List
-
-
+ <> + + + ( + + setSelected(isSelected ? [...credentialTypes] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + , + ] + : []), + , + ]} + /> + )} + renderItem={credentialType => ( + handleSelect(credentialType)} + isSelected={selected.some(row => row.id === credentialType.id)} + /> + )} + emptyStateControls={ + canAdd && ( + + ) + } + /> + + + + {i18n._(t`Failed to delete one or more credential types.`)} + + + ); } -export default CredentialTypeList; +export default withI18n()(CredentialTypeList); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx new file mode 100644 index 0000000000..adeda5255b --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import { CredentialTypesAPI } from '../../../api'; +import CredentialTypeList from './CredentialTypeList'; + +jest.mock('../../../api/models/CredentialTypes'); + +const credentialTypes = { + data: { + results: [ + { + id: 1, + name: 'Foo', + kind: 'cloud', + summary_fields: { + user_capabilities: { edit: true, delete: true }, + }, + url: '', + }, + { + id: 2, + name: 'Bar', + kind: 'cloud', + summary_fields: { + user_capabilities: { edit: false, delete: true }, + }, + url: '', + }, + ], + count: 2, + }, +}; + +const options = { data: { actions: { POST: true } } }; + +describe(' { + let wrapper; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + }); + + test('should have data fetched and render 2 rows', async () => { + CredentialTypesAPI.read.mockResolvedValue(credentialTypes); + CredentialTypesAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + expect(wrapper.find('CredentialTypeListItem').length).toBe(2); + expect(CredentialTypesAPI.read).toBeCalled(); + expect(CredentialTypesAPI.readOptions).toBeCalled(); + }); + + test('should delete item successfully', async () => { + CredentialTypesAPI.read.mockResolvedValue(credentialTypes); + CredentialTypesAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + + wrapper + .find('input#select-credential-types-1') + .simulate('change', credentialTypes.data.results[0]); + wrapper.update(); + + expect( + wrapper.find('input#select-credential-types-1').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(CredentialTypesAPI.destroy).toBeCalledWith( + credentialTypes.data.results[0].id + ); + }); + + test('should thrown content error', async () => { + CredentialTypesAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'GET', + url: '/api/v2/credential_types', + }, + data: 'An error occurred', + }, + }) + ); + CredentialTypesAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + CredentialTypesAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'DELETE', + url: '/api/v2/credential_types', + }, + data: 'An error occurred', + }, + }) + ); + CredentialTypesAPI.read.mockResolvedValue(credentialTypes); + CredentialTypesAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + + wrapper.find('input#select-credential-types-1').simulate('change', 'a'); + wrapper.update(); + expect( + wrapper.find('input#select-credential-types-1').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 () => { + CredentialTypesAPI.read.mockResolvedValue(credentialTypes); + CredentialTypesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx new file mode 100644 index 0000000000..10591907a2 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx @@ -0,0 +1,92 @@ +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 { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import DataListCell from '../../../components/DataListCell'; +import { CredentialType } from '../../../types'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + +function CredentialTypeListItem({ + credentialType, + detailUrl, + isSelected, + onSelect, + i18n, +}) { + const labelId = `check-action-${credentialType.id}`; + + return ( + + + + + + {credentialType.name} + + , + ]} + /> + + {credentialType.summary_fields.user_capabilities.edit && ( + + + + )} + + + + ); +} + +CredentialTypeListItem.prototype = { + credentialType: CredentialType.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(CredentialTypeListItem); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx new file mode 100644 index 0000000000..3cb4f6cc52 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import CredentialTypeListItem from './CredentialTypeListItem'; + +describe('', () => { + let wrapper; + const credential_type = { + id: 1, + name: 'Foo', + summary_fields: { user_capabilities: { edit: true, delete: true } }, + kind: 'cloud', + }; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('CredentialTypeListItem').length).toBe(1); + }); + + test('should render the proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('DataListCell[aria-label="credential type name"]').text() + ).toBe('Foo'); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + expect( + wrapper.find('input#select-credential-types-1').prop('checked') + ).toBe(false); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('input#select-credential-types-1').prop('checked') + ).toBe(true); + }); + + test('edit button shown to users with edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +});