diff --git a/awx/ui_next/src/components/JobCancelButton/JobCancelButton.jsx b/awx/ui_next/src/components/JobCancelButton/JobCancelButton.jsx
new file mode 100644
index 0000000000..3bbee07474
--- /dev/null
+++ b/awx/ui_next/src/components/JobCancelButton/JobCancelButton.jsx
@@ -0,0 +1,102 @@
+import React, { useCallback, useState } from 'react';
+import { t } from '@lingui/macro';
+import { MinusCircleIcon } from '@patternfly/react-icons';
+import { Button, Tooltip } from '@patternfly/react-core';
+import { getJobModel } from '../../util/jobs';
+import useRequest, { useDismissableError } from '../../util/useRequest';
+import AlertModal from '../AlertModal';
+import ErrorDetail from '../ErrorDetail';
+
+function JobCancelButton({
+ job = {},
+ errorTitle,
+ title,
+ showIconButton,
+ errorMessage,
+ buttonText,
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const { error: cancelError, request: cancelJob } = useRequest(
+ useCallback(async () => {
+ setIsOpen(false);
+ await getJobModel(job.type).cancel(job.id);
+ }, [job.id, job.type]),
+ {}
+ );
+ const { error, dismissError: dismissCancelError } = useDismissableError(
+ cancelError
+ );
+
+ return (
+ <>
+
+ {showIconButton ? (
+
+ ) : (
+
+ )}
+
+ {isOpen && (
+ setIsOpen(false)}
+ title={title}
+ label={title}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {t`Are you sure you want to cancel this job?`}
+
+ )}
+ {error && (
+
+ {errorMessage}
+
+
+ )}
+ >
+ );
+}
+
+export default JobCancelButton;
diff --git a/awx/ui_next/src/components/JobCancelButton/JobCancelButton.test.jsx b/awx/ui_next/src/components/JobCancelButton/JobCancelButton.test.jsx
new file mode 100644
index 0000000000..dd98838ac2
--- /dev/null
+++ b/awx/ui_next/src/components/JobCancelButton/JobCancelButton.test.jsx
@@ -0,0 +1,180 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ ProjectUpdatesAPI,
+ AdHocCommandsAPI,
+ SystemJobsAPI,
+ WorkflowJobsAPI,
+ JobsAPI,
+} from '../../api';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import JobCancelButton from './JobCancelButton';
+
+jest.mock('../../api');
+
+describe('', () => {
+ let wrapper;
+
+ test('should render properly', () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.length).toBe(1);
+ expect(wrapper.find('MinusCircleIcon').length).toBe(0);
+ });
+ test('should render icon button', () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('MinusCircleIcon').length).toBe(1);
+ });
+ test('should call api', async () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ expect(ProjectUpdatesAPI.cancel).toBeCalledWith(1);
+ });
+ test('should throw error', async () => {
+ ProjectUpdatesAPI.cancel.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/projectupdates',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ expect(wrapper.find('AlertModal[title="Title"]').length).toBe(0);
+ });
+
+ test('should cancel Ad Hoc Command job', async () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ expect(AdHocCommandsAPI.cancel).toBeCalledWith(1);
+ });
+
+ test('should cancel system job', async () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ expect(SystemJobsAPI.cancel).toBeCalledWith(1);
+ });
+
+ test('should cancel workflow job', async () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ expect(WorkflowJobsAPI.cancel).toBeCalledWith(1);
+ });
+ test('should cancel workflow job', async () => {
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () => wrapper.find('Button').prop('onClick')(true));
+ wrapper.update();
+
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ await act(() =>
+ wrapper.find('Button#cancel-job-confirm-button').prop('onClick')()
+ );
+ expect(JobsAPI.cancel).toBeCalledWith(1);
+ });
+});
diff --git a/awx/ui_next/src/components/JobCancelButton/index.js b/awx/ui_next/src/components/JobCancelButton/index.js
new file mode 100644
index 0000000000..6940bcd1f9
--- /dev/null
+++ b/awx/ui_next/src/components/JobCancelButton/index.js
@@ -0,0 +1 @@
+export { default } from './JobCancelButton';
diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx
index 87641d67f6..eaaa26095c 100644
--- a/awx/ui_next/src/components/JobList/JobList.jsx
+++ b/awx/ui_next/src/components/JobList/JobList.jsx
@@ -12,6 +12,8 @@ import useRequest, {
useDeleteItems,
useDismissableError,
} from '../../util/useRequest';
+import { useConfig } from '../../contexts/Config';
+
import { isJobRunning, getJobModel } from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem';
@@ -32,6 +34,8 @@ function JobList({ defaultParams, showTypeColumn = false }) {
['id', 'page', 'page_size']
);
+ const { me } = useConfig();
+
const [selected, setSelected] = useState([]);
const location = useLocation();
const {
@@ -261,6 +265,7 @@ function JobList({ defaultParams, showTypeColumn = false }) {
handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)}
diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx
index ad1215f8c6..a9f7304abb 100644
--- a/awx/ui_next/src/components/JobList/JobListItem.jsx
+++ b/awx/ui_next/src/components/JobList/JobListItem.jsx
@@ -15,6 +15,7 @@ import CredentialChip from '../CredentialChip';
import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail';
import { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
+import JobCancelButton from '../JobCancelButton';
const Dash = styled.span``;
function JobListItem({
@@ -23,6 +24,7 @@ function JobListItem({
isSelected,
onSelect,
showTypeColumn = false,
+ isSuperUser = false,
}) {
const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false);
@@ -83,6 +85,20 @@ function JobListItem({
{job.finished ? formatDateString(job.finished) : ''}
+
+
+
| {label} |
-
-
-
+ {['running', 'pending', 'waiting'].includes(source?.status) ? (
+
+
+
+ ) : (
+
+
+
+ )}
{
- const {
- data: {
- summary_fields: {
- current_update: { id },
- },
- },
- } = await InventorySourcesAPI.readDetail(source.id);
-
- await InventoryUpdatesAPI.createSyncCancel(id);
- }, [source.id])
- );
-
const {
error: startError,
dismissError: dismissStartError,
} = useDismissableError(startSyncError);
- const {
- error: cancelError,
- dismissError: dismissCancelError,
- } = useDismissableError(cancelSyncError);
return (
<>
- {['running', 'pending', 'updating'].includes(source.status) ? (
-
-
-
- ) : (
-
-
-
- )}
+
+
+
+
{startError && (
)}
- {cancelError && (
-
- {t`Failed to cancel inventory source sync.`}
-
-
- )}
>
);
}
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSyncButton.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSyncButton.test.jsx
index 9adb77c0b3..b7c8508d99 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSyncButton.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSyncButton.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api';
+import { InventorySourcesAPI } from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import InventorySourceSyncButton from './InventorySourceSyncButton';
@@ -36,17 +36,6 @@ describe('', () => {
).toBe(false);
});
- test('should render cancel sync button', () => {
- wrapper = mountWithContexts(
- {}}
- />
- );
- expect(wrapper.find('MinusCircleIcon').length).toBe(1);
- });
-
test('should start sync properly', async () => {
InventorySourcesAPI.createSyncStart.mockResolvedValue({
data: { status: 'pending' },
@@ -58,33 +47,6 @@ describe('', () => {
expect(InventorySourcesAPI.createSyncStart).toBeCalledWith(1);
});
- test('should cancel sync properly', async () => {
- InventorySourcesAPI.readDetail.mockResolvedValue({
- data: { summary_fields: { current_update: { id: 120 } } },
- });
- InventoryUpdatesAPI.createSyncCancel.mockResolvedValue({
- data: { status: '' },
- });
-
- wrapper = mountWithContexts(
- {}}
- />
- );
- expect(wrapper.find('Button[aria-label="Cancel sync source"]').length).toBe(
- 1
- );
-
- await act(async () =>
- wrapper.find('Button[aria-label="Cancel sync source"]').simulate('click')
- );
-
- expect(InventorySourcesAPI.readDetail).toBeCalledWith(1);
- expect(InventoryUpdatesAPI.createSyncCancel).toBeCalledWith(120);
- });
-
test('should throw error on sync start properly', async () => {
InventorySourcesAPI.createSyncStart.mockRejectedValueOnce(
new Error({
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index c8759694a0..27dc9252e8 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -1,11 +1,12 @@
import 'styled-components/macro';
-import React, { useCallback, useState } from 'react';
+import React, { useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import { Button, Chip } from '@patternfly/react-core';
import styled from 'styled-components';
+import { useConfig } from '../../../contexts/Config';
import AlertModal from '../../../components/AlertModal';
import {
DetailList,
@@ -24,10 +25,10 @@ import {
ReLaunchDropDown,
} from '../../../components/LaunchButton';
import StatusIcon from '../../../components/StatusIcon';
+import JobCancelButton from '../../../components/JobCancelButton';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import { getJobModel, isJobRunning } from '../../../util/jobs';
import { toTitleCase } from '../../../util/strings';
-import useRequest, { useDismissableError } from '../../../util/useRequest';
import { formatDateString } from '../../../util/dates';
import { Job } from '../../../types';
@@ -53,6 +54,7 @@ const VERBOSITY = {
};
function JobDetail({ job }) {
+ const { me } = useConfig();
const {
created_by,
credential,
@@ -72,24 +74,6 @@ function JobDetail({ job }) {
const [errorMsg, setErrorMsg] = useState();
const history = useHistory();
- const [showCancelModal, setShowCancelModal] = useState(false);
-
- const {
- error: cancelError,
- isLoading: isCancelling,
- request: cancelJob,
- } = useRequest(
- useCallback(async () => {
- await getJobModel(job.type).cancel(job.id, job.type);
- }, [job.id, job.type]),
- {}
- );
-
- const {
- error: dismissableCancelError,
- dismissError: dismissCancelError,
- } = useDismissableError(cancelError);
-
const jobTypes = {
project_update: t`Source Control Update`,
inventory_update: t`Inventory Sync`,
@@ -394,16 +378,15 @@ function JobDetail({ job }) {
))}
{isJobRunning(job.status) &&
- job?.summary_fields?.user_capabilities?.start && (
-
+ (job.type === 'system_job'
+ ? me.is_superuser
+ : job?.summary_fields?.user_capabilities?.start) && (
+
)}
{!isJobRunning(job.status) &&
job?.summary_fields?.user_capabilities?.delete && (
@@ -417,49 +400,6 @@ function JobDetail({ job }) {
)}
- {showCancelModal && isJobRunning(job.status) && (
- setShowCancelModal(false)}
- title={t`Cancel Job`}
- label={t`Cancel Job`}
- actions={[
- ,
- ,
- ]}
- >
- {t`Are you sure you want to submit the request to cancel this job?`}
-
- )}
- {dismissableCancelError && (
-
-
-
- )}
{errorMsg && (
', () => {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
test('should display details', () => {
wrapper = mountWithContexts(
@@ -145,18 +150,15 @@ describe('', () => {
});
test('DELETED is shown for required Job resources that have been deleted', () => {
- wrapper = mountWithContexts(
-
- );
+ const newMockData = {
+ ...mockJobData,
+ summary_fields: {
+ ...mockJobData.summary_fields,
+ inventory: null,
+ project: null,
+ },
+ };
+ wrapper = mountWithContexts();
const detail = wrapper.find('JobDetail');
async function assertMissingDetail(label) {
expect(detail.length).toBe(1);
@@ -178,4 +180,128 @@ describe('', () => {
);
assertDetail('Job Type', 'Playbook Check');
});
+
+ test('should not show cancel job button, not super user', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/miscellaneous_system/edit'],
+ });
+
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ config: {
+ me: {
+ is_superuser: false,
+ },
+ },
+ },
+ }
+ );
+ expect(
+ wrapper.find('Button[aria-label="Cancel Demo Job Template"]')
+ ).toHaveLength(0);
+ });
+
+ test('should not show cancel job button, job completed', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/miscellaneous_system/edit'],
+ });
+
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ config: {
+ me: {
+ is_superuser: true,
+ },
+ },
+ },
+ }
+ );
+ expect(
+ wrapper.find('Button[aria-label="Cancel Demo Job Template"]')
+ ).toHaveLength(0);
+ });
+
+ test('should show cancel button, pending, super user', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/miscellaneous_system/edit'],
+ });
+
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ config: {
+ me: {
+ is_superuser: true,
+ },
+ },
+ },
+ }
+ );
+ expect(
+ wrapper.find('Button[aria-label="Cancel Demo Job Template"]')
+ ).toHaveLength(1);
+ });
+
+ test('should show cancel button, pending, super project update, not super user', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/miscellaneous_system/edit'],
+ });
+
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ config: {
+ me: {
+ is_superuser: false,
+ },
+ },
+ },
+ }
+ );
+ expect(
+ wrapper.find('Button[aria-label="Cancel Demo Job Template"]')
+ ).toHaveLength(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
index c46d5547a2..6a600d245e 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx
@@ -4,7 +4,6 @@ import styled from 'styled-components';
import { t } from '@lingui/macro';
import { bool, shape, func } from 'prop-types';
import {
- MinusCircleIcon,
DownloadIcon,
RocketIcon,
TrashAltIcon,
@@ -15,6 +14,9 @@ import {
LaunchButton,
ReLaunchDropDown,
} from '../../../../components/LaunchButton';
+import { useConfig } from '../../../../contexts/Config';
+
+import JobCancelButton from '../../../../components/JobCancelButton';
const BadgeGroup = styled.div`
margin-left: 20px;
@@ -62,13 +64,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update',
];
-const OutputToolbar = ({
- job,
- onDelete,
- onCancel,
- isDeleteDisabled,
- jobStatus,
-}) => {
+const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count;
@@ -79,6 +75,7 @@ const OutputToolbar = ({
(sum, key) => sum + job?.host_status_counts[key],
0
);
+ const { me } = useConfig();
return (
@@ -131,43 +128,53 @@ const OutputToolbar = ({
{toHHMMSS(job.elapsed)}
-
- {job.type !== 'system_job' &&
- job.summary_fields.user_capabilities?.start && (
-
- {job.status === 'failed' && job.type === 'job' ? (
-
- {({ handleRelaunch, isLaunching }) => (
-
- )}
-
- ) : (
-
- {({ handleRelaunch, isLaunching }) => (
-
- )}
-
- )}
-
+ {['pending', 'waiting', 'running'].includes(jobStatus) &&
+ (job.type === 'system_job'
+ ? me.is_superuser
+ : job?.summary_fields?.user_capabilities?.start) && (
+
)}
+ {job.summary_fields.user_capabilities?.start && (
+
+ {job.status === 'failed' && job.type === 'job' ? (
+
+ {({ handleRelaunch, isLaunching }) => (
+
+ )}
+
+ ) : (
+
+ {({ handleRelaunch, isLaunching }) => (
+
+ )}
+
+ )}
+
+ )}
{job.related?.stdout && (
@@ -182,19 +189,6 @@ const OutputToolbar = ({
)}
- {job.summary_fields.user_capabilities.start &&
- ['pending', 'waiting', 'running'].includes(jobStatus) && (
-
-
-
- )}
{job.summary_fields.user_capabilities.delete &&
['new', 'successful', 'failed', 'error', 'canceled'].includes(
jobStatus
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
index 22ae88d711..675b39c6d0 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
@@ -15,6 +15,7 @@ import {
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
+import JobCancelButton from '../../../components/JobCancelButton';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import CredentialChip from '../../../components/CredentialChip';
import { ProjectsAPI } from '../../../api';
@@ -199,18 +200,27 @@ function ProjectDetail({ project }) {
{t`Edit`}
)}
- {summary_fields.user_capabilities?.start && (
-
- )}
+ {summary_fields.user_capabilities?.start &&
+ (['running', 'pending', 'waiting'].includes(job?.status) ? (
+
+ ) : (
+
+ ))}
{summary_fields.user_capabilities?.delete && (
@@ -218,7 +228,6 @@ function ProjectDetail({ project }) {
)}
- {/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
{error && (
-
-
-
+ {['running', 'pending', 'waiting'].includes(job?.status) ? (
+
+
+
+ ) : (
+
+
+
+ )}