Adds cancel button to jobs list toolbar

This commit is contained in:
mabashian 2020-08-25 10:05:44 -04:00
parent 6889128571
commit f27b541396
4 changed files with 415 additions and 11 deletions

View File

@ -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 }) {
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
items={jobs}
itemCount={count}
pluralizedItemName={i18n._(t`Jobs`)}
@ -242,6 +276,10 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
itemsToDelete={selected}
pluralizedItemName="Jobs"
/>,
<JobListCancelButton
onCancel={handleJobCancel}
jobsToCancel={selected}
/>,
]}
/>
)}
@ -256,15 +294,28 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
)}
/>
</Card>
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
{deletionError && (
<AlertModal
isOpen
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more jobs.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
{cancelError && (
<AlertModal
isOpen
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissCancelError}
>
{i18n._(t`Failed to cancel one or more jobs.`)}
<ErrorDetail error={cancelError} />
</AlertModal>
)}
</>
);
}

View File

@ -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('<JobList />', () => {
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(<JobList />);
});
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(<JobList />);
});
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!'
);
});
});

View File

@ -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 (
<div>
{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}
</div>
);
}
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 (
<Kebabified>
{({ isKebabified }) => (
<>
{isKebabified ? (
<DropdownItem
key="cancel-job"
isDisabled={isDisabled}
component="Button"
onClick={() => setIsModalOpen(true)}
>
{cancelJobText}
</DropdownItem>
) : (
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
variant="secondary"
aria-label={cancelJobText}
onClick={() => setIsModalOpen(true)}
isDisabled={isDisabled}
>
{cancelJobText}
</Button>
</div>
</Tooltip>
)}
{isModalOpen && (
<AlertModal
variant="danger"
title={cancelJobText}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
actions={[
<Button
key="delete"
variant="danger"
aria-label={cancelJobText}
onClick={handleCancel}
>
{cancelJobText}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={() => setIsModalOpen(false)}
>
{i18n._(t`Return`)}
</Button>,
]}
>
<div>
{i18n.plural({
value: jobsToCancel.length,
one: 'This action will cancel the following job:',
other: 'This action will cancel the following jobs:',
})}
</div>
{jobsToCancel.map(job => (
<span key={job.id}>
<strong>{job.name}</strong>
<br />
</span>
))}
</AlertModal>
)}
</>
)}
</Kebabified>
);
}
JobListCancelButton.propTypes = {
jobsToCancel: arrayOf(Job),
onCancel: func,
};
JobListCancelButton.defaultProps = {
jobsToCancel: [],
onCancel: () => {},
};
export default withI18n()(JobListCancelButton);

View File

@ -0,0 +1,110 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import JobListCancelButton from './JobListCancelButton';
describe('<JobListCancelButton />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('should be disabled when no rows are selected', () => {
wrapper = mountWithContexts(<JobListCancelButton jobsToCancel={[]} />);
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(
<JobListCancelButton
jobsToCancel={[
{
id: 1,
name: 'some job',
summary_fields: {
user_capabilities: {
delete: false,
start: false,
},
},
},
]}
/>
);
expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
true
);
const tooltipContents = wrapper.find('Tooltip').props().content;
const renderedTooltipContents = shallow(tooltipContents);
expect(
renderedTooltipContents.matchesElement(
<div>
You do not have permission to cancel the following job: some job
</div>
)
).toBe(true);
});
test('should be enabled when user does have permission to cancel selected job', () => {
wrapper = mountWithContexts(
<JobListCancelButton
jobsToCancel={[
{
id: 1,
name: 'some job',
summary_fields: {
user_capabilities: {
delete: true,
start: true,
},
},
},
]}
/>
);
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(
<JobListCancelButton
jobsToCancel={[
{
id: 1,
name: 'some job',
summary_fields: {
user_capabilities: {
delete: true,
start: true,
},
},
},
]}
onCancel={onCancel}
/>
);
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);
});
});