From bd660254a50d37ee3eac1e46c21300f8ce553154 Mon Sep 17 00:00:00 2001 From: nixocio Date: Wed, 24 Jun 2020 09:46:54 -0400 Subject: [PATCH] Add Crendential Type Details Add credential type Details See: https://github.com/ansible/awx/issues/7430 --- .../screens/CredentialType/CredentialType.jsx | 130 ++++++++++++++--- .../CredentialType/CredentialType.test.jsx | 58 ++++++++ .../CredentialTypeDetails.jsx | 105 +++++++++++++- .../CredentialTypeDetails.test.jsx | 136 ++++++++++++++++++ .../CredentialTypeList/CredentialTypeList.jsx | 1 + .../CredentialTypeListItem.jsx | 2 +- .../CredentialType/CredentialTypes.jsx | 4 + 7 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialType.test.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx diff --git a/awx/ui_next/src/screens/CredentialType/CredentialType.jsx b/awx/ui_next/src/screens/CredentialType/CredentialType.jsx index 1d5b2ecca3..121020b569 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialType.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialType.jsx @@ -1,25 +1,121 @@ -import React from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; + +import useRequest from '../../util/useRequest'; +import { CredentialTypesAPI } from '../../api'; +import RoutedTabs from '../../components/RoutedTabs'; +import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; import CredentialTypeDetails from './CredentialTypeDetails'; import CredentialTypeEdit from './CredentialTypeEdit'; -function CredentialType() { +function CredentialType({ i18n, setBreadcrumb }) { + const { id } = useParams(); + const { pathname } = useLocation(); + + const { + isLoading, + error: contentError, + request: fetchCredentialTypes, + result: credentialType, + } = useRequest( + useCallback(async () => { + const { data } = await CredentialTypesAPI.readDetail(id); + return data; + }, [id]) + ); + + useEffect(() => { + fetchCredentialTypes(); + }, [fetchCredentialTypes, pathname]); + + useEffect(() => { + if (credentialType) { + setBreadcrumb(credentialType); + } + }, [credentialType, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to credential types`)} + + ), + link: '/credential_types', + id: 99, + }, + { + name: i18n._(t`Details`), + link: `/credential_types/${id}/details`, + id: 0, + }, + ]; + + let cardHeader = ; + if (pathname.endsWith('edit')) { + cardHeader = null; + } + + if (!isLoading && contentError) { + return ( + + + + {contentError.response?.status === 404 && ( + + {i18n._(t`Credential type not found.`)}{' '} + + {i18n._(t`View all credential types`)} + + + )} + + + + ); + } + return ( - - - - - - - - - + + + {cardHeader} + {isLoading && } + {!isLoading && credentialType && ( + + + {credentialType && ( + <> + + + + + + + + )} + + )} + + ); } -export default CredentialType; +export default withI18n()(CredentialType); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialType.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialType.test.jsx new file mode 100644 index 0000000000..36c7ff68a3 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialType.test.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import { CredentialTypesAPI } from '../../api'; + +import CredentialType from './CredentialType'; + +jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/credential_types', + }), + useParams: () => ({ id: 42 }), +})); + +describe('', () => { + let wrapper; + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); + wrapper.update(); + expect(wrapper.find('CredentialType').length).toBe(1); + expect(CredentialTypesAPI.readDetail).toBeCalledWith(42); + }); + + test('should render expected tabs', async () => { + const expectedTabs = ['Back to credential types', 'Details']; + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/credential_types/42/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts( {}} />, { + context: { + router: { + history, + }, + }, + }); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx index 58543324b7..d622c9d6f1 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.jsx @@ -1,12 +1,103 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; +import React, { useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link, useHistory } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; + +import { VariablesDetail } from '../../../components/CodeMirrorInput'; +import AlertModal from '../../../components/AlertModal'; +import { CardBody, CardActionsRow } from '../../../components/Card'; +import DeleteButton from '../../../components/DeleteButton'; +import { + Detail, + DetailList, + UserDateDetail, +} from '../../../components/DetailList'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { CredentialTypesAPI } from '../../../api'; +import { jsonToYaml } from '../../../util/yaml'; + +function CredentialTypeDetails({ credentialType, i18n }) { + const { id, name, description, injectors, inputs } = credentialType; + const history = useHistory(); + + const { + request: deleteCredentialType, + isLoading, + error: deleteError, + } = useRequest( + useCallback(async () => { + await CredentialTypesAPI.destroy(id); + history.push(`/credential_types`); + }, [id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); -function CredentialTypeDetails() { return ( - - Credential Type Details - + + + + + + + + + + + {credentialType.summary_fields.user_capabilities && + credentialType.summary_fields.user_capabilities.edit && ( + + )} + {credentialType.summary_fields.user_capabilities && + credentialType.summary_fields.user_capabilities.delete && ( + + {i18n._(t`Delete`)} + + )} + + + {error && ( + + )} + ); } -export default CredentialTypeDetails; +export default withI18n()(CredentialTypeDetails); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx new file mode 100644 index 0000000000..92cad273f9 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { CredentialTypesAPI } from '../../../api'; +import { jsonToYaml } from '../../../util/yaml'; + +import CredentialTypeDetails from './CredentialTypeDetails'; + +jest.mock('../../../api'); + +const credentialTypeData = { + name: 'Foo', + description: 'Bar', + kind: 'cloud', + inputs: { + fields: [ + { + id: 'username', + type: 'string', + label: 'Jenkins username', + }, + { + id: 'password', + type: 'string', + label: 'Jenkins password', + secret: true, + }, + ], + required: ['username', 'password'], + }, + injectors: { + extra_vars: { + Jenkins_password: '{{ password }}', + Jenkins_username: '{{ username }}', + }, + }, + summary_fields: { + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-06-25T16:52:36.127008Z', + modified: '2020-06-25T16:52:36.127022Z', +}; + +function expectDetailToMatch(wrapper, label, value) { + const detail = wrapper.find(`Detail[label="${label}"]`); + expect(detail).toHaveLength(1); + expect(detail.prop('value')).toEqual(value); +} + +describe('', () => { + let wrapper; + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expectDetailToMatch(wrapper, 'Name', credentialTypeData.name); + expectDetailToMatch(wrapper, 'Description', credentialTypeData.description); + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(credentialTypeData.created); + expect(dates.at(1).prop('date')).toEqual(credentialTypeData.modified); + const vars = wrapper.find('VariablesDetail'); + expect(vars).toHaveLength(2); + + expect(vars.at(0).prop('label')).toEqual('Input configuration'); + expect(vars.at(0).prop('value')).toEqual( + jsonToYaml(JSON.stringify(credentialTypeData.inputs)) + ); + expect(vars.at(1).prop('label')).toEqual('Injector configuration'); + expect(vars.at(1).prop('value')).toEqual( + jsonToYaml(JSON.stringify(credentialTypeData.injectors)) + ); + }); + + test('expected api call is made for delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/credential_types/42/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(CredentialTypesAPI.destroy).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/credential_types'); + }); + + test('should not render delete button', async () => { + credentialTypeData.summary_fields.user_capabilities.delete = false; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0); + }); + test('should not render edit button', async () => { + credentialTypeData.summary_fields.user_capabilities.edit = false; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index ec807a5721..7375a61b63 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -15,6 +15,7 @@ import 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', { diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx index 10591907a2..580a5f5a7e 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx @@ -53,7 +53,7 @@ function CredentialTypeListItem({ key="name" aria-label={i18n._(t`credential type name`)} > - + {credentialType.name} , diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx index 0f2c2efb0c..9f6b5b734a 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypes.jsx @@ -23,6 +23,10 @@ function CredentialTypes({ i18n }) { '/credential_types': i18n._(t`Credential Types`), '/credential_types/add': i18n._(t`Create Credential Types`), [`/credential_types/${credentialTypes.id}`]: `${credentialTypes.name}`, + [`/credential_types/${credentialTypes.id}/edit`]: i18n._( + t`Edit Details` + ), + [`/credential_types/${credentialTypes.id}/details`]: i18n._(t`Details`), }); }, [i18n]