diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.jsx
index dc1fe1922a..ac87305218 100644
--- a/awx/ui_next/src/screens/Job/JobList/JobList.jsx
+++ b/awx/ui_next/src/screens/Job/JobList/JobList.jsx
@@ -1,5 +1,5 @@
-import React, { Component } from 'react';
-import { withRouter } from 'react-router-dom';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@@ -19,6 +19,7 @@ import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
+import useRequest, { useDeleteItems } from '@util/useRequest';
import { getQSConfig, parseQueryString } from '@util/qs';
import JobListItem from './JobListItem';
@@ -34,257 +35,217 @@ const QS_CONFIG = getQSConfig(
['page', 'page_size', 'id']
);
-class JobList extends Component {
- constructor(props) {
- super(props);
+function JobList({ i18n }) {
+ const [selected, setSelected] = useState([]);
+ const location = useLocation();
- this.state = {
- hasContentLoading: true,
- deletionError: null,
- contentError: null,
- selected: [],
- jobs: [],
- itemCount: 0,
- };
- this.loadJobs = this.loadJobs.bind(this);
- this.handleSelectAll = this.handleSelectAll.bind(this);
- this.handleSelect = this.handleSelect.bind(this);
- this.handleJobDelete = this.handleJobDelete.bind(this);
- this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
- }
-
- componentDidMount() {
- this.loadJobs();
- }
-
- componentDidUpdate(prevProps) {
- const { location } = this.props;
- if (location !== prevProps.location) {
- this.loadJobs();
- }
- }
-
- handleDeleteErrorClose() {
- this.setState({ deletionError: null });
- }
-
- handleSelectAll(isSelected) {
- const { jobs } = this.state;
- const selected = isSelected ? [...jobs] : [];
- this.setState({ selected });
- }
-
- handleSelect(item) {
- const { selected } = this.state;
- if (selected.some(s => s.id === item.id)) {
- this.setState({ selected: selected.filter(s => s.id !== item.id) });
- } else {
- this.setState({ selected: selected.concat(item) });
- }
- }
-
- async handleJobDelete() {
- const { selected, itemCount } = this.state;
- this.setState({ hasContentLoading: true });
- try {
- await Promise.all(
- selected.map(({ type, id }) => {
- let deletePromise;
- switch (type) {
- case 'job':
- deletePromise = JobsAPI.destroy(id);
- break;
- case 'ad_hoc_command':
- deletePromise = AdHocCommandsAPI.destroy(id);
- break;
- case 'system_job':
- deletePromise = SystemJobsAPI.destroy(id);
- break;
- case 'project_update':
- deletePromise = ProjectUpdatesAPI.destroy(id);
- break;
- case 'inventory_update':
- deletePromise = InventoryUpdatesAPI.destroy(id);
- break;
- case 'workflow_job':
- deletePromise = WorkflowJobsAPI.destroy(id);
- break;
- default:
- break;
- }
- return deletePromise;
- })
- );
- this.setState({ itemCount: itemCount - selected.length });
- } catch (err) {
- this.setState({ deletionError: err });
- } finally {
- await this.loadJobs();
- }
- }
-
- async loadJobs() {
- const { location } = this.props;
- const params = parseQueryString(QS_CONFIG, location.search);
-
- this.setState({ contentError: null, hasContentLoading: true });
- try {
+ const {
+ result: { jobs, itemCount },
+ error: contentError,
+ isLoading,
+ request: fetchJobs,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { count, results },
} = await UnifiedJobsAPI.read(params);
- this.setState({
+ return {
itemCount: count,
jobs: results,
- selected: [],
- });
- } catch (err) {
- this.setState({ contentError: err });
- } finally {
- this.setState({ hasContentLoading: false });
+ };
+ }, [location]),
+ {
+ jobs: [],
+ itemCount: 0,
}
- }
+ );
- render() {
- const {
- contentError,
- hasContentLoading,
- deletionError,
- jobs,
- itemCount,
- selected,
- } = this.state;
- const { match, i18n } = this.props;
- const isAllSelected =
- selected.length === jobs.length && selected.length > 0;
- return (
-
-
- (
- ,
- ]}
- />
- )}
- renderItem={job => (
- this.handleSelect(job)}
- isSelected={selected.some(row => row.id === job.id)}
- />
- )}
- />
-
-
- {i18n._(t`Failed to delete one or more jobs.`)}
-
-
-
- );
- }
+ useEffect(() => {
+ fetchJobs();
+ }, [fetchJobs]);
+
+ const isAllSelected = selected.length === jobs.length && selected.length > 0;
+ const {
+ isLoading: isDeleteLoading,
+ deleteItems: deleteJobs,
+ deletionError,
+ clearDeletionError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(({ type, id }) => {
+ switch (type) {
+ case 'job':
+ return JobsAPI.destroy(id);
+ case 'ad_hoc_command':
+ return AdHocCommandsAPI.destroy(id);
+ case 'system_job':
+ return SystemJobsAPI.destroy(id);
+ case 'project_update':
+ return ProjectUpdatesAPI.destroy(id);
+ case 'inventory_update':
+ return InventoryUpdatesAPI.destroy(id);
+ case 'workflow_job':
+ return WorkflowJobsAPI.destroy(id);
+ default:
+ return null;
+ }
+ })
+ );
+ }, [selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchJobs,
+ }
+ );
+
+ const handleJobDelete = async () => {
+ await deleteJobs();
+ setSelected([]);
+ };
+
+ const handleSelectAll = isSelected => {
+ setSelected(isSelected ? [...jobs] : []);
+ };
+
+ const handleSelect = item => {
+ if (selected.some(s => s.id === item.id)) {
+ setSelected(selected.filter(s => s.id !== item.id));
+ } else {
+ setSelected(selected.concat(item));
+ }
+ };
+
+ return (
+
+
+ (
+ ,
+ ]}
+ />
+ )}
+ renderItem={job => (
+ handleSelect(job)}
+ isSelected={selected.some(row => row.id === job.id)}
+ />
+ )}
+ />
+
+
+ {i18n._(t`Failed to delete one or more jobs.`)}
+
+
+
+ );
}
-export { JobList as _JobList };
-export default withI18n()(withRouter(JobList));
+// export { JobList as _JobList };
+export default withI18n()(JobList);
diff --git a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx
index 7c1654dcd4..7379b4079a 100644
--- a/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx
+++ b/awx/ui_next/src/screens/Job/JobList/JobList.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
+import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-
import {
AdHocCommandsAPI,
InventoryUpdatesAPI,
@@ -87,58 +87,118 @@ UnifiedJobsAPI.read.mockResolvedValue({
data: { count: 3, results: mockResults },
});
+function waitForLoaded(wrapper) {
+ return waitForElement(
+ wrapper,
+ 'JobList',
+ el => el.find('ContentLoading').length === 0
+ );
+}
+
describe('', () => {
- test('initially renders succesfully', async done => {
- const wrapper = mountWithContexts();
- await waitForElement(
- wrapper,
- 'JobList',
- el => el.state('jobs').length === 6
- );
-
- done();
+ test('initially renders succesfully', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForLoaded(wrapper);
+ expect(wrapper.find('JobListItem')).toHaveLength(6);
});
- test('select makes expected state updates', async done => {
+ test('should select and un-select items', async () => {
const [mockItem] = mockResults;
- const wrapper = mountWithContexts();
- await waitForElement(wrapper, 'JobListItem', el => el.length === 6);
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForLoaded(wrapper);
- wrapper
- .find('JobListItem')
- .first()
- .prop('onSelect')(mockItem);
- expect(wrapper.find('JobList').state('selected').length).toEqual(1);
+ act(() => {
+ wrapper
+ .find('JobListItem')
+ .first()
+ .invoke('onSelect')(mockItem);
+ });
+ wrapper.update();
+ expect(
+ wrapper
+ .find('JobListItem')
+ .first()
+ .prop('isSelected')
+ ).toEqual(true);
+ expect(
+ wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
+ ).toHaveLength(1);
- wrapper
- .find('JobListItem')
- .first()
- .prop('onSelect')(mockItem);
- expect(wrapper.find('JobList').state('selected').length).toEqual(0);
-
- done();
+ act(() => {
+ wrapper
+ .find('JobListItem')
+ .first()
+ .invoke('onSelect')(mockItem);
+ });
+ wrapper.update();
+ expect(
+ wrapper
+ .find('JobListItem')
+ .first()
+ .prop('isSelected')
+ ).toEqual(false);
+ expect(
+ wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
+ ).toHaveLength(0);
});
- test('select-all-delete makes expected state updates and api calls', async done => {
+ test('should select and deselect all', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForLoaded(wrapper);
+
+ act(() => {
+ wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
+ });
+ wrapper.update();
+ wrapper.find('JobListItem');
+ expect(
+ wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
+ ).toHaveLength(6);
+
+ act(() => {
+ wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
+ });
+ wrapper.update();
+ wrapper.find('JobListItem');
+ expect(
+ wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
+ ).toHaveLength(0);
+ });
+
+ test('should send all corresponding delete API requests', async () => {
AdHocCommandsAPI.destroy = jest.fn();
InventoryUpdatesAPI.destroy = jest.fn();
JobsAPI.destroy = jest.fn();
ProjectUpdatesAPI.destroy = jest.fn();
SystemJobsAPI.destroy = jest.fn();
WorkflowJobsAPI.destroy = jest.fn();
- const wrapper = mountWithContexts();
- await waitForElement(wrapper, 'JobListItem', el => el.length === 6);
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForLoaded(wrapper);
- wrapper.find('DataListToolbar').prop('onSelectAll')(true);
- expect(wrapper.find('JobList').state('selected').length).toEqual(6);
+ act(() => {
+ wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
+ });
+ wrapper.update();
+ wrapper.find('JobListItem');
+ expect(
+ wrapper.find('ToolbarDeleteButton').prop('itemsToDelete')
+ ).toHaveLength(6);
- wrapper.find('DataListToolbar').prop('onSelectAll')(false);
- expect(wrapper.find('JobList').state('selected').length).toEqual(0);
-
- wrapper.find('DataListToolbar').prop('onSelectAll')(true);
- expect(wrapper.find('JobList').state('selected').length).toEqual(6);
-
- wrapper.find('ToolbarDeleteButton').prop('onDelete')();
+ await act(async () => {
+ wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
+ });
expect(AdHocCommandsAPI.destroy).toHaveBeenCalledTimes(1);
expect(InventoryUpdatesAPI.destroy).toHaveBeenCalledTimes(1);
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
@@ -146,12 +206,12 @@ describe('', () => {
expect(SystemJobsAPI.destroy).toHaveBeenCalledTimes(1);
expect(WorkflowJobsAPI.destroy).toHaveBeenCalledTimes(1);
- done();
+ jest.restoreAllMocks();
});
- test('error is shown when job not successfully deleted from api', async done => {
- JobsAPI.destroy.mockRejectedValue(
- new Error({
+ test('error is shown when job not successfully deleted from api', async () => {
+ JobsAPI.destroy.mockImplementation(() => {
+ throw new Error({
response: {
config: {
method: 'delete',
@@ -159,21 +219,29 @@ describe('', () => {
},
data: 'An error occurred',
},
- })
- );
- const wrapper = mountWithContexts();
- wrapper.find('JobList').setState({
- jobs: mockResults,
- itemCount: 6,
- selected: mockResults.slice(1, 2),
+ });
});
- wrapper.find('ToolbarDeleteButton').prop('onDelete')();
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForLoaded(wrapper);
+ await act(async () => {
+ wrapper
+ .find('JobListItem')
+ .at(1)
+ .invoke('onSelect')();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
+ });
+ wrapper.update();
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
-
- done();
});
});
diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
index 4af0ddae40..a417db86db 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx
@@ -89,11 +89,7 @@ function OrganizationsList({ i18n }) {
const canAdd = actions && actions.POST;
const handleSelectAll = isSelected => {
- if (isSelected) {
- setSelected(organizations);
- } else {
- setSelected([]);
- }
+ setSelected(isSelected ? [...organizations] : []);
};
const handleSelect = row => {
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
index 167dc83106..cc688886b0 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
@@ -105,8 +105,7 @@ function TemplateList({ i18n }) {
};
const handleSelectAll = isSelected => {
- const selectedItems = isSelected ? [...templates] : [];
- setSelected(selectedItems);
+ setSelected(isSelected ? [...templates] : []);
};
const handleSelect = template => {