diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index 36330716fd..f7567750d6 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -1,4 +1,5 @@
import AdHocCommands from './models/AdHocCommands';
+import Applications from './models/Applications';
import Config from './models/Config';
import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
@@ -31,6 +32,7 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands();
+const ApplicationsAPI = new Applications();
const ConfigAPI = new Config();
const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
@@ -64,6 +66,7 @@ const WorkflowJobsAPI = new WorkflowJobs();
export {
AdHocCommandsAPI,
+ ApplicationsAPI,
ConfigAPI,
CredentialInputSourcesAPI,
CredentialTypesAPI,
diff --git a/awx/ui_next/src/api/models/Applications.js b/awx/ui_next/src/api/models/Applications.js
new file mode 100644
index 0000000000..50b709bdca
--- /dev/null
+++ b/awx/ui_next/src/api/models/Applications.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class Applications extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/applications/';
+ }
+}
+
+export default Applications;
diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx
new file mode 100644
index 0000000000..367886f2a9
--- /dev/null
+++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import { ApplicationsAPI } from '../../../api';
+import ApplicationsList from './ApplicationsList';
+
+jest.mock('../../../api/models/Applications');
+
+const applications = {
+ data: {
+ results: [
+ {
+ id: 1,
+ name: 'Foo',
+ summary_fields: {
+ organization: { name: 'Org 1', id: 10 },
+ user_capabilities: { edit: true, delete: true },
+ },
+ url: '',
+ organiation: 10,
+ },
+ {
+ id: 2,
+ name: 'Bar',
+ summary_fields: {
+ organization: { name: 'Org 2', id: 20 },
+ user_capabilities: { edit: true, delete: true },
+ },
+ url: '',
+ organization: 20,
+ },
+ ],
+ count: 2,
+ },
+};
+const options = { data: { actions: { POST: true } } };
+describe('', () => {
+ let wrapper;
+ test('should mount properly', async () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+ });
+ test('should have data fetched and render 2 rows', async () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+ expect(wrapper.find('ApplicationListItem').length).toBe(2);
+ expect(ApplicationsAPI.read).toBeCalled();
+ expect(ApplicationsAPI.readOptions).toBeCalled();
+ });
+
+ test('should delete item successfully', async () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+
+ wrapper
+ .find('input#select-application-1')
+ .simulate('change', applications.data.results[0]);
+
+ wrapper.update();
+
+ expect(wrapper.find('input#select-application-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(ApplicationsAPI.destroy).toBeCalledWith(
+ applications.data.results[0].id
+ );
+ });
+
+ test('should throw content error', async () => {
+ ApplicationsAPI.read.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/applications/',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+
+ await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+
+ test('should render deletion error modal', async () => {
+ ApplicationsAPI.destroy.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/applications/',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue(options);
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+
+ wrapper.find('input#select-application-1').simulate('change', 'a');
+
+ wrapper.update();
+
+ expect(wrapper.find('input#select-application-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 () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: false } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+ expect(wrapper.find('ToolbarAddButton').length).toBe(0);
+ });
+ test('should not render edit button for first list item', async () => {
+ applications.data.results[0].summary_fields.user_capabilities.edit = false;
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ ApplicationsAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: false } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ waitForElement(wrapper, 'ApplicationsList', el => el.length > 0);
+ expect(
+ wrapper
+ .find('ApplicationListItem')
+ .at(0)
+ .find('PencilAltIcon').length
+ ).toBe(0);
+ expect(
+ wrapper
+ .find('ApplicationListItem')
+ .at(1)
+ .find('PencilAltIcon').length
+ ).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx
new file mode 100644
index 0000000000..ddd0df7bc0
--- /dev/null
+++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { string, bool, func } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import {
+ Button,
+ DataListAction as _DataListAction,
+ DataListCheck,
+ DataListItem,
+ DataListItemCells,
+ DataListItemRow,
+ Tooltip,
+} from '@patternfly/react-core';
+
+import { t } from '@lingui/macro';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { PencilAltIcon } from '@patternfly/react-icons';
+import { Application } from '../../../types';
+import DataListCell from '../../../components/DataListCell';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px;
+`;
+
+function ApplicationListItem({
+ application,
+ isSelected,
+ onSelect,
+ detailUrl,
+ i18n,
+}) {
+ ApplicationListItem.propTypes = {
+ application: Application.isRequired,
+ detailUrl: string.isRequired,
+ isSelected: bool.isRequired,
+ onSelect: func.isRequired,
+ };
+
+ const labelId = `check-action-${application.id}`;
+ return (
+
+
+
+
+
+ {application.name}
+
+ ,
+
+
+ {application.summary_fields.organization.name}
+
+ ,
+ ]}
+ />
+
+ {application.summary_fields.user_capabilities.edit ? (
+
+
+
+ ) : (
+ ''
+ )}
+
+
+
+ );
+}
+export default withI18n()(ApplicationListItem);
diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx
new file mode 100644
index 0000000000..0a53dd4cd8
--- /dev/null
+++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+
+import ApplicationListItem from './ApplicationListItem';
+
+describe('', () => {
+ let wrapper;
+ const application = {
+ id: 1,
+ name: 'Foo',
+ summary_fields: {
+ organization: { id: 2, name: 'Organization' },
+ user_capabilities: { edit: true },
+ },
+ };
+ test('should mount successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ expect(wrapper.find('ApplicationListItem').length).toBe(1);
+ });
+ test('should render the proper data', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ expect(
+ wrapper.find('DataListCell[aria-label="application name"]').text()
+ ).toBe('Foo');
+ expect(
+ wrapper.find('DataListCell[aria-label="organization name"]').text()
+ ).toBe('Organization');
+ expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
+ false
+ );
+ expect(wrapper.find('PencilAltIcon').length).toBe(1);
+ });
+ test('should be checked', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ expect(wrapper.find('input#select-application-1').prop('checked')).toBe(
+ true
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx
index 6fcf16bb73..f2869c5d2b 100644
--- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx
+++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx
@@ -1,15 +1,180 @@
-import React from 'react';
-import { Card, PageSection } from '@patternfly/react-core';
+import React, { useCallback, useEffect } from 'react';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+import { useLocation, useRouteMatch } from 'react-router-dom';
+
+import { Card, PageSection } from '@patternfly/react-core';
+import { getQSConfig, parseQueryString } from '../../../util/qs';
+import useRequest, { useDeleteItems } from '../../../util/useRequest';
+import ErrorDetail from '../../../components/ErrorDetail';
+import AlertModal from '../../../components/AlertModal';
+
+import DatalistToolbar from '../../../components/DataListToolbar';
+import { ApplicationsAPI } from '../../../api';
+import PaginatedDataList, {
+ ToolbarDeleteButton,
+ ToolbarAddButton,
+} from '../../../components/PaginatedDataList';
+import useSelected from '../../../util/useSelected';
+
+import ApplicationListItem from './ApplicationListItem';
+
+const QS_CONFIG = getQSConfig('inventory', {
+ page: 1,
+ page_size: 20,
+ order_by: 'name',
+});
+function ApplicationsList({ i18n }) {
+ const location = useLocation();
+ const match = useRouteMatch();
+
+ const {
+ isLoading,
+ error,
+ request: fetchApplications,
+ result: { applications, itemCount, actions },
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+
+ const [response, actionsResponse] = await Promise.all([
+ ApplicationsAPI.read(params),
+ ApplicationsAPI.readOptions(),
+ ]);
+
+ return {
+ applications: response.data.results,
+ itemCount: response.data.count,
+ actions: actionsResponse.data.actions,
+ };
+ }, [location]),
+ {
+ applications: [],
+ itemCount: 0,
+ actions: {},
+ }
+ );
+
+ useEffect(() => {
+ fetchApplications();
+ }, [fetchApplications]);
+
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ applications
+ );
+
+ const {
+ isLoading: deleteLoading,
+ deletionError,
+ deleteItems: handleDeleteApplications,
+ clearDeletionError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ await Promise.all(selected.map(({ id }) => ApplicationsAPI.destroy(id)));
+ }, [selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchApplications,
+ }
+ );
+
+ const handleDelete = async () => {
+ await handleDeleteApplications();
+ setSelected([]);
+ };
+
+ const canAdd = actions && actions.POST;
-function ApplicationsList() {
return (
<>
- Applications List
+ (
+
+ setSelected(isSelected ? [...applications] : [])
+ }
+ qsConfig={QS_CONFIG}
+ additionalControls={[
+ ...(canAdd
+ ? [
+ ,
+ ]
+ : []),
+ ,
+ ]}
+ />
+ )}
+ renderItem={application => (
+ handleSelect(application)}
+ isSelected={selected.some(row => row.id === application.id)}
+ />
+ )}
+ emptyStateControls={
+ canAdd && (
+
+ )
+ }
+ />
+
+ {i18n._(t`Failed to delete one or more applications.`)}
+
+
>
);
}
-export default ApplicationsList;
+export default withI18n()(ApplicationsList);
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index 52e07d53cb..5e60df659a 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -42,6 +42,18 @@ export const AccessRecord = shape({
type: string,
});
+export const Application = shape({
+ id: number.isRequired,
+ name: string.isRequired,
+ organization: number,
+ summary_fields: shape({
+ organization: shape({
+ id: number.isRequired,
+ name: string.isRequired,
+ }),
+ }),
+});
+
export const Organization = shape({
id: number.isRequired,
name: string.isRequired,