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();
+ });
+});