mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Adds cancel button to jobs list toolbar
This commit is contained in:
parent
6889128571
commit
f27b541396
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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!'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
141
awx/ui_next/src/components/JobList/JobListCancelButton.jsx
Normal file
141
awx/ui_next/src/components/JobList/JobListCancelButton.jsx
Normal 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);
|
||||
110
awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx
Normal file
110
awx/ui_next/src/components/JobList/JobListCancelButton.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user