diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx
index 502b4378c7..b4cb3c940c 100644
--- a/awx/ui_next/src/components/JobList/JobList.jsx
+++ b/awx/ui_next/src/components/JobList/JobList.jsx
@@ -8,15 +8,20 @@ import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar';
import ErrorDetail from '../ErrorDetail';
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
-import useRequest, { useDeleteItems } from '../../util/useRequest';
+import useRequest, {
+ useDeleteItems,
+ useDismissableError,
+} from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem';
+import JobListCancelButton from './JobListCancelButton';
import useWsJobs from './useWsJobs';
import {
AdHocCommandsAPI,
InventoryUpdatesAPI,
JobsAPI,
ProjectUpdatesAPI,
+ RelatedAPI,
SystemJobsAPI,
UnifiedJobsAPI,
WorkflowJobsAPI,
@@ -88,6 +93,30 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
const isAllSelected = selected.length === jobs.length && selected.length > 0;
+
+ const {
+ error: cancelJobsError,
+ isLoading: isCancelLoading,
+ request: cancelJobs,
+ } = useRequest(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(job => {
+ if (['new', 'pending', 'waiting', 'running'].includes(job.status)) {
+ return RelatedAPI.post(job.related.cancel);
+ }
+ return Promise.resolve();
+ })
+ );
+ }, [selected]),
+ {}
+ );
+
+ const {
+ error: cancelError,
+ dismissError: dismissCancelError,
+ } = useDismissableError(cancelJobsError);
+
const {
isLoading: isDeleteLoading,
deleteItems: deleteJobs,
@@ -123,6 +152,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
}
);
+ const handleJobCancel = async () => {
+ await cancelJobs();
+ setSelected([]);
+ };
+
const handleJobDelete = async () => {
await deleteJobs();
setSelected([]);
@@ -145,7 +179,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
,
+ ,
]}
/>
)}
@@ -256,15 +294,28 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
)}
/>
-
- {i18n._(t`Failed to delete one or more jobs.`)}
-
-
+ {deletionError && (
+
+ {i18n._(t`Failed to delete one or more jobs.`)}
+
+
+ )}
+ {cancelError && (
+
+ {i18n._(t`Failed to cancel one or more jobs.`)}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/components/JobList/JobList.test.jsx b/awx/ui_next/src/components/JobList/JobList.test.jsx
index 767243c59b..ffa2aff960 100644
--- a/awx/ui_next/src/components/JobList/JobList.test.jsx
+++ b/awx/ui_next/src/components/JobList/JobList.test.jsx
@@ -9,6 +9,7 @@ import {
InventoryUpdatesAPI,
JobsAPI,
ProjectUpdatesAPI,
+ RelatedAPI,
SystemJobsAPI,
UnifiedJobsAPI,
WorkflowJobsAPI,
@@ -23,6 +24,10 @@ const mockResults = [
url: '/api/v2/project_updates/1',
name: 'job 1',
type: 'project_update',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/project_updates/1/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -35,6 +40,10 @@ const mockResults = [
url: '/api/v2/jobs/2',
name: 'job 2',
type: 'job',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/jobs/2/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -47,6 +56,10 @@ const mockResults = [
url: '/api/v2/inventory_updates/3',
name: 'job 3',
type: 'inventory_update',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/inventory_updates/3/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -59,6 +72,10 @@ const mockResults = [
url: '/api/v2/workflow_jobs/4',
name: 'job 4',
type: 'workflow_job',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/workflow_jobs/4/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -71,6 +88,10 @@ const mockResults = [
url: '/api/v2/system_jobs/5',
name: 'job 5',
type: 'system_job',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/system_jobs/5/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -83,6 +104,10 @@ const mockResults = [
url: '/api/v2/ad_hoc_commands/6',
name: 'job 6',
type: 'ad_hoc_command',
+ status: 'running',
+ related: {
+ cancel: '/api/v2/ad_hoc_commands/6/cancel',
+ },
summary_fields: {
user_capabilities: {
delete: true,
@@ -273,4 +298,81 @@ describe(' ', () => {
el => el.props().isOpen === true && el.props().title === 'Error!'
);
});
+
+ test('should send all corresponding delete API requests', async () => {
+ RelatedAPI.post = jest.fn();
+ 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('JobListCancelButton').prop('jobsToCancel')
+ ).toHaveLength(6);
+
+ await act(async () => {
+ wrapper.find('JobListCancelButton').invoke('onCancel')();
+ });
+ expect(RelatedAPI.post).toHaveBeenCalledTimes(6);
+ expect(RelatedAPI.post).toHaveBeenCalledWith(
+ '/api/v2/project_updates/1/cancel'
+ );
+ expect(RelatedAPI.post).toHaveBeenCalledWith('/api/v2/jobs/2/cancel');
+ expect(RelatedAPI.post).toHaveBeenCalledWith(
+ '/api/v2/inventory_updates/3/cancel'
+ );
+ expect(RelatedAPI.post).toHaveBeenCalledWith(
+ '/api/v2/workflow_jobs/4/cancel'
+ );
+ expect(RelatedAPI.post).toHaveBeenCalledWith(
+ '/api/v2/system_jobs/5/cancel'
+ );
+ expect(RelatedAPI.post).toHaveBeenCalledWith(
+ '/api/v2/ad_hoc_commands/6/cancel'
+ );
+
+ jest.restoreAllMocks();
+ });
+
+ test('error is shown when job not successfully cancelled', async () => {
+ RelatedAPI.post.mockImplementation(() => {
+ throw new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/jobs/2/cancel',
+ },
+ data: 'An error occurred',
+ },
+ });
+ });
+ 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('JobListCancelButton').invoke('onCancel')();
+ });
+ wrapper.update();
+ await waitForElement(
+ wrapper,
+ 'Modal',
+ el => el.props().isOpen === true && el.props().title === 'Error!'
+ );
+ });
});
diff --git a/awx/ui_next/src/components/JobList/JobListCancelButton.jsx b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx
new file mode 100644
index 0000000000..a178034479
--- /dev/null
+++ b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { arrayOf, func } from 'prop-types';
+import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
+import { Kebabified } from '../../contexts/Kebabified';
+import AlertModal from '../AlertModal';
+import { Job } from '../../types';
+
+function cannotCancel(job) {
+ return !job.summary_fields.user_capabilities.start;
+}
+
+function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const handleCancel = () => {
+ onCancel();
+ setIsModalOpen(false);
+ };
+
+ const renderTooltip = () => {
+ const jobsUnableToCancel = jobsToCancel
+ .filter(cannotCancel)
+ .map(job => job.name)
+ .join(', ');
+ if (jobsToCancel.some(cannotCancel)) {
+ return (
+
+ {i18n.plural({
+ value: jobsToCancel.length,
+ one: 'You do not have permission to cancel the following job: ',
+ other: 'You do not have permission to cancel the following jobs: ',
+ })}
+ {jobsUnableToCancel}
+
+ );
+ }
+ if (jobsToCancel.length) {
+ return i18n.plural({
+ value: jobsToCancel.length,
+ one: 'Cancel selected job',
+ other: 'Cancel selected jobs',
+ });
+ }
+ return i18n._(t`Select a job to cancel`);
+ };
+
+ const isDisabled =
+ jobsToCancel.length === 0 || jobsToCancel.some(cannotCancel);
+
+ const cancelJobText = i18n.plural({
+ value: jobsToCancel.length < 2,
+ one: 'Cancel job',
+ other: 'Cancel jobs',
+ });
+
+ return (
+
+ {({ isKebabified }) => (
+ <>
+ {isKebabified ? (
+ setIsModalOpen(true)}
+ >
+ {cancelJobText}
+
+ ) : (
+
+
+ setIsModalOpen(true)}
+ isDisabled={isDisabled}
+ >
+ {cancelJobText}
+
+
+
+ )}
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ actions={[
+
+ {cancelJobText}
+ ,
+ setIsModalOpen(false)}
+ >
+ {i18n._(t`Return`)}
+ ,
+ ]}
+ >
+
+ {i18n.plural({
+ value: jobsToCancel.length,
+ one: 'This action will cancel the following job:',
+ other: 'This action will cancel the following jobs:',
+ })}
+
+ {jobsToCancel.map(job => (
+
+ {job.name}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+JobListCancelButton.propTypes = {
+ jobsToCancel: arrayOf(Job),
+ onCancel: func,
+};
+
+JobListCancelButton.defaultProps = {
+ jobsToCancel: [],
+ onCancel: () => {},
+};
+
+export default withI18n()(JobListCancelButton);
diff --git a/awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx b/awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx
new file mode 100644
index 0000000000..7f085615cf
--- /dev/null
+++ b/awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import JobListCancelButton from './JobListCancelButton';
+
+describe(' ', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('should be disabled when no rows are selected', () => {
+ wrapper = mountWithContexts( );
+ expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
+ true
+ );
+ expect(wrapper.find('Tooltip').props().content).toBe(
+ 'Select a job to cancel'
+ );
+ });
+ test('should be disabled when user does not have permissions to cancel selected job', () => {
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
+ true
+ );
+ const tooltipContents = wrapper.find('Tooltip').props().content;
+ const renderedTooltipContents = shallow(tooltipContents);
+ expect(
+ renderedTooltipContents.matchesElement(
+
+ You do not have permission to cancel the following job: some job
+
+ )
+ ).toBe(true);
+ });
+ test('should be enabled when user does have permission to cancel selected job', () => {
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
+ false
+ );
+ expect(wrapper.find('Tooltip').props().content).toBe('Cancel selected job');
+ });
+ test('modal functions as expected', () => {
+ const onCancel = jest.fn();
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ wrapper.find('JobListCancelButton button').simulate('click');
+ wrapper.update();
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ wrapper.find('AlertModal button[aria-label="Return"]').simulate('click');
+ wrapper.update();
+ expect(onCancel).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ wrapper.find('JobListCancelButton button').simulate('click');
+ wrapper.update();
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ wrapper
+ .find('AlertModal button[aria-label="Cancel job"]')
+ .simulate('click');
+ wrapper.update();
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});