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]