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} + + ) : ( + +
+ +
+
+ )} + {isModalOpen && ( + setIsModalOpen(false)} + actions={[ + , + , + ]} + > +
+ {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); + }); +});