diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index 06b929c0ba..9c43509f9e 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -1,13 +1,29 @@ import Base from '../Base'; import RelaunchMixin from '../mixins/Relaunch.mixin'; -const BASE_URLS = { - playbook: '/jobs/', - project: '/project_updates/', - system: '/system_jobs/', - inventory: '/inventory_updates/', - command: '/ad_hoc_commands/', - workflow: '/workflow_jobs/', +const getBaseURL = type => { + switch (type) { + case 'playbook': + case 'job': + return '/jobs/'; + case 'project': + case 'project_update': + return '/project_updates/'; + case 'system': + case 'system_job': + return '/system_jobs/'; + case 'inventory': + case 'inventory_update': + return '/inventory_updates/'; + case 'command': + case 'ad_hoc_command': + return '/ad_hoc_commands/'; + case 'workflow': + case 'workflow_job': + return '/workflow_jobs/'; + default: + throw new Error('Unable to find matching job type'); + } }; class Jobs extends RelaunchMixin(Base) { @@ -16,16 +32,20 @@ class Jobs extends RelaunchMixin(Base) { this.baseUrl = '/api/v2/jobs/'; } + cancel(id, type) { + return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`); + } + readDetail(id, type) { - return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`); + return this.http.get(`/api/v2${getBaseURL(type)}${id}/`); } readEvents(id, type = 'playbook', params = {}) { let endpoint; if (type === 'playbook') { - endpoint = `/api/v2${BASE_URLS[type]}${id}/job_events/`; + endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`; } else { - endpoint = `/api/v2${BASE_URLS[type]}${id}/events/`; + endpoint = `/api/v2${getBaseURL(type)}${id}/events/`; } return this.http.get(endpoint, { params }); } diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 1ddfb57df6..f5137d905b 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -1,4 +1,4 @@ -import React, { Fragment, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -42,14 +42,21 @@ function DataListToolbar({ pagination, }) { const showExpandCollapse = onCompact && onExpand; - const [kebabIsOpen, setKebabIsOpen] = useState(false); - const [advancedSearchShown, setAdvancedSearchShown] = useState(false); + const [isKebabOpen, setIsKebabOpen] = useState(false); + const [isKebabModalOpen, setIsKebabModalOpen] = useState(false); + const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false); const onShowAdvancedSearch = shown => { - setAdvancedSearchShown(shown); - setKebabIsOpen(false); + setIsAdvancedSearchShown(shown); + setIsKebabOpen(false); }; + useEffect(() => { + if (!isKebabModalOpen) { + setIsKebabOpen(false); + } + }, [isKebabModalOpen]); + return ( {showExpandCollapse && ( - + <> - + )} - {advancedSearchShown && ( + {isAdvancedSearchShown && ( - } - isOpen={kebabIsOpen} - isPlain - dropdownItems={additionalControls.map(control => { - return ( - - {control} - - ); - })} - /> + + { + if (!isKebabModalOpen) { + setIsKebabOpen(isOpen); + } + }} + /> + } + isOpen={isKebabOpen} + isPlain + dropdownItems={additionalControls} + /> + )} - {!advancedSearchShown && ( + {!isAdvancedSearchShown && ( {additionalControls.map(control => ( {control} ))} )} - {!advancedSearchShown && pagination && itemCount > 0 && ( + {!isAdvancedSearchShown && pagination && itemCount > 0 && ( {pagination} )} diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 502b4378c7..c08401acbc 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -8,9 +8,13 @@ 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, @@ -88,6 +92,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 JobsAPI.cancel(job.id, job.type); + } + return Promise.resolve(); + }) + ); + }, [selected]), + {} + ); + + const { + error: cancelError, + dismissError: dismissCancelError, + } = useDismissableError(cancelJobsError); + const { isLoading: isDeleteLoading, deleteItems: deleteJobs, @@ -123,6 +151,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { } ); + const handleJobCancel = async () => { + await cancelJobs(); + setSelected([]); + }; + const handleJobDelete = async () => { await deleteJobs(); setSelected([]); @@ -145,7 +178,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..87f74abfeb 100644 --- a/awx/ui_next/src/components/JobList/JobList.test.jsx +++ b/awx/ui_next/src/components/JobList/JobList.test.jsx @@ -23,6 +23,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 +39,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 +55,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 +71,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 +87,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 +103,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 +297,72 @@ describe('', () => { el => el.props().isOpen === true && el.props().title === 'Error!' ); }); + + test('should send all corresponding delete API requests', async () => { + JobsAPI.cancel = 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(JobsAPI.cancel).toHaveBeenCalledTimes(6); + expect(JobsAPI.cancel).toHaveBeenCalledWith(1, 'project_update'); + expect(JobsAPI.cancel).toHaveBeenCalledWith(2, 'job'); + expect(JobsAPI.cancel).toHaveBeenCalledWith(3, 'inventory_update'); + expect(JobsAPI.cancel).toHaveBeenCalledWith(4, 'workflow_job'); + expect(JobsAPI.cancel).toHaveBeenCalledWith(5, 'system_job'); + expect(JobsAPI.cancel).toHaveBeenCalledWith(6, 'ad_hoc_command'); + + jest.restoreAllMocks(); + }); + + test('error is shown when job not successfully cancelled', async () => { + JobsAPI.cancel.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..684d088c96 --- /dev/null +++ b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx @@ -0,0 +1,156 @@ +import React, { useContext, useEffect, 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 { KebabifiedContext } 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 { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); + const [isModalOpen, setIsModalOpen] = useState(false); + const numJobsToCancel = jobsToCancel.length; + const zeroOrOneJobSelected = numJobsToCancel < 2; + + const handleCancelJob = () => { + onCancel(); + toggleModal(); + }; + + const toggleModal = () => { + setIsModalOpen(!isModalOpen); + }; + + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isModalOpen); + } + }, [isKebabified, isModalOpen, onKebabModalChange]); + + const renderTooltip = () => { + const jobsUnableToCancel = jobsToCancel + .filter(cannotCancel) + .map(job => job.name); + const numJobsUnableToCancel = jobsUnableToCancel.length; + if (numJobsUnableToCancel > 0) { + return ( +
+ {i18n._( + '{numJobsUnableToCancel, plural, one {You do not have permission to cancel the following job:} other {You do not have permission to cancel the following jobs:}}', + { + numJobsUnableToCancel, + } + )} + {' '.concat(jobsUnableToCancel.join(', '))} +
+ ); + } + if (numJobsToCancel > 0) { + return i18n._( + '{numJobsToCancel, plural, one {Cancel selected job} other {Cancel selected jobs}}', + { + numJobsToCancel, + } + ); + } + return i18n._(t`Select a job to cancel`); + }; + + const isDisabled = + jobsToCancel.length === 0 || jobsToCancel.some(cannotCancel); + + const cancelJobText = i18n._( + '{zeroOrOneJobSelected, plural, one {Cancel job} other {Cancel jobs}}', + { + zeroOrOneJobSelected, + } + ); + + return ( + <> + {isKebabified ? ( + + {cancelJobText} + + ) : ( + +
+ +
+
+ )} + {isModalOpen && ( + + {cancelJobText} + , + , + ]} + > +
+ {i18n._( + '{numJobsToCancel, plural, one {This action will cancel the following job:} other {This action will cancel the following jobs:}}', + { + numJobsToCancel, + } + )} +
+ {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..43bdbe1352 --- /dev/null +++ b/awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +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 + ); + }); + test('should be enabled when user does have permission to cancel selected job', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('JobListCancelButton button').props().disabled).toBe( + false + ); + }); + 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('button#cancel-job-return-button').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('button#cancel-job-confirm-button').simulate('click'); + wrapper.update(); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index 7be476dfc1..25a280d549 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { func, bool, @@ -12,7 +12,7 @@ import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; -import { Kebabified } from '../../contexts/Kebabified'; +import { KebabifiedContext } from '../../contexts/Kebabified'; const requireNameOrUsername = props => { const { name, username } = props; @@ -59,53 +59,32 @@ function cannotDelete(item) { return !item.summary_fields.user_capabilities.delete; } -class ToolbarDeleteButton extends React.Component { - static propTypes = { - onDelete: func.isRequired, - itemsToDelete: arrayOf(ItemToDelete).isRequired, - pluralizedItemName: string, - errorMessage: string, - }; +function ToolbarDeleteButton({ + itemsToDelete, + pluralizedItemName, + errorMessage, + onDelete, + i18n, +}) { + const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); + const [isModalOpen, setIsModalOpen] = useState(false); - static defaultProps = { - pluralizedItemName: 'Items', - errorMessage: '', - }; - - constructor(props) { - super(props); - - this.state = { - isModalOpen: false, - }; - - this.handleConfirmDelete = this.handleConfirmDelete.bind(this); - this.handleCancelDelete = this.handleCancelDelete.bind(this); - this.handleDelete = this.handleDelete.bind(this); - } - - handleConfirmDelete() { - this.setState({ isModalOpen: true }); - } - - handleCancelDelete() { - this.setState({ isModalOpen: false }); - } - - handleDelete() { - const { onDelete } = this.props; + const handleDelete = () => { onDelete(); - this.setState({ isModalOpen: false }); - } + toggleModal(); + }; - renderTooltip() { - const { - itemsToDelete, - pluralizedItemName, - errorMessage, - i18n, - } = this.props; + const toggleModal = () => { + setIsModalOpen(!isModalOpen); + }; + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isModalOpen); + } + }, [isKebabified, isModalOpen, onKebabModalChange]); + + const renderTooltip = () => { const itemsUnableToDelete = itemsToDelete .filter(cannotDelete) .map(item => item.name) @@ -125,85 +104,89 @@ class ToolbarDeleteButton extends React.Component { return i18n._(t`Delete`); } return i18n._(t`Select a row to delete`); - } + }; - render() { - const { itemsToDelete, pluralizedItemName, i18n } = this.props; - const { isModalOpen } = this.state; - const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`); + const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`); - const isDisabled = - itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete); + const isDisabled = + itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete); - // NOTE: Once PF supports tooltips on disabled elements, - // we can delete the extra
around the below. - // See: https://github.com/patternfly/patternfly-react/issues/1894 - return ( - - {({ isKebabified }) => ( - - {isKebabified ? ( - - {i18n._(t`Delete`)} - - ) : ( - -
- -
-
- )} - {isModalOpen && ( - - {i18n._(t`Delete`)} - , - , - ]} - > -
{i18n._(t`This action will delete the following:`)}
- {itemsToDelete.map(item => ( - - {item.name || item.username} -
-
- ))} -
- )} -
- )} -
- ); - } + // NOTE: Once PF supports tooltips on disabled elements, + // we can delete the extra
around the below. + // See: https://github.com/patternfly/patternfly-react/issues/1894 + return ( + <> + {isKebabified ? ( + + {i18n._(t`Delete`)} + + ) : ( + +
+ +
+
+ )} + {isModalOpen && ( + + {i18n._(t`Delete`)} + , + , + ]} + > +
{i18n._(t`This action will delete the following:`)}
+ {itemsToDelete.map(item => ( + + {item.name || item.username} +
+
+ ))} +
+ )} + + ); } +ToolbarDeleteButton.propTypes = { + onDelete: func.isRequired, + itemsToDelete: arrayOf(ItemToDelete).isRequired, + pluralizedItemName: string, + errorMessage: string, +}; + +ToolbarDeleteButton.defaultProps = { + pluralizedItemName: 'Items', + errorMessage: '', +}; + export default withI18n()(ToolbarDeleteButton); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx index 843ffa5b4a..c85730226c 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.test.jsx @@ -26,8 +26,8 @@ describe('', () => { const wrapper = mountWithContexts( {}} itemsToDelete={[itemA]} /> ); + expect(wrapper.find('Modal')).toHaveLength(0); wrapper.find('button').simulate('click'); - expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(true); wrapper.update(); expect(wrapper.find('Modal')).toHaveLength(1); }); @@ -37,15 +37,14 @@ describe('', () => { const wrapper = mountWithContexts( ); - wrapper.find('ToolbarDeleteButton').setState({ isModalOpen: true }); + wrapper.find('button').simulate('click'); wrapper.update(); wrapper .find('ModalBoxFooter button[aria-label="confirm delete"]') .simulate('click'); + wrapper.update(); expect(onDelete).toHaveBeenCalled(); - expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe( - false - ); + expect(wrapper.find('Modal')).toHaveLength(0); }); test('should disable button when no delete permissions', () => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx index 377e87583e..2486ea5579 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx @@ -261,7 +261,9 @@ describe('', () => { }, }); - wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); wrapper.update(); await act(async () => { await wrapper @@ -302,7 +304,9 @@ describe('', () => { summary_fields: { user_capabilities: { delete: true } }, }, }); - wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Delete"]').prop('onClick')(); + }); wrapper.update(); await act(async () => { await wrapper