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) ? ( + + + + ) : ( + + + + )}