mirror of
https://github.com/ansible/awx.git
synced 2026-01-16 20:30:46 -03:30
Merge pull request #10113 from AlexSCorey/10045-ProjectListIssues
Adds Job Cancel Button SUMMARY This addresses part of #10045 and adds a sync cancel button on the projects list. It also expands the usage of that button to the Project details page, and the Inventory Source list. It does this by introducing a new component called JobCancelButton, that basically takes the work of the job cancel button on the Output toolbar and refactors it slightly to make it useable in these other areas. This button could also be used in the Inventory Source details page once we have websockets hooked up for that view and we can track the status of the sync. (#9013) ISSUE TYPE Feature Pull Request COMPONENT NAME UI ADDITIONAL INFORMATION Reviewed-by: Jake McDermott <yo@jakemcdermott.me> Reviewed-by: Kersom <None> Reviewed-by: Alex Corey <Alex.swansboro@gmail.com> Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
commit
550a66553e
102
awx/ui_next/src/components/JobCancelButton/JobCancelButton.jsx
Normal file
102
awx/ui_next/src/components/JobCancelButton/JobCancelButton.jsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Tooltip content={title}>
|
||||
{showIconButton ? (
|
||||
<Button
|
||||
aria-label={title}
|
||||
ouiaId="cancel-job-button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="plain"
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={title}
|
||||
variant="secondary"
|
||||
ouiaId="cancel-job-button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{buttonText || t`Cancel Job`}
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
variant="danger"
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={title}
|
||||
label={title}
|
||||
actions={[
|
||||
<Button
|
||||
id="cancel-job-confirm-button"
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={t`Confirm cancel job`}
|
||||
ouiaId="cancel-job-confirm-button"
|
||||
onClick={cancelJob}
|
||||
>
|
||||
{t`Confirm cancellation`}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-job-return-button"
|
||||
key="cancel"
|
||||
ouiaId="return"
|
||||
aria-label={t`Return`}
|
||||
variant="secondary"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t`Return`}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{t`Are you sure you want to cancel this job?`}
|
||||
</AlertModal>
|
||||
)}
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="danger"
|
||||
onClose={dismissCancelError}
|
||||
title={errorTitle}
|
||||
label={errorTitle}
|
||||
>
|
||||
{errorMessage}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default JobCancelButton;
|
||||
@ -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('<JobCancelButton/>', () => {
|
||||
let wrapper;
|
||||
|
||||
test('should render properly', () => {
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'project_update' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('MinusCircleIcon').length).toBe(0);
|
||||
});
|
||||
test('should render icon button', () => {
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'project_update' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('MinusCircleIcon').length).toBe(1);
|
||||
});
|
||||
test('should call api', async () => {
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'project_update' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<JobCancelButton
|
||||
job={{ id: 'a', type: 'project_update' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'ad_hoc_command' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'system_job' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'workflow_job' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<JobCancelButton
|
||||
job={{ id: 1, type: 'hakunah_matata' }}
|
||||
errorTitle="Error"
|
||||
title="Title"
|
||||
showIconButton
|
||||
/>
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/JobCancelButton/index.js
Normal file
1
awx/ui_next/src/components/JobCancelButton/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './JobCancelButton';
|
||||
@ -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 }) {
|
||||
<JobListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
isSuperUser={me?.is_superuser}
|
||||
showTypeColumn={showTypeColumn}
|
||||
onSelect={() => handleSelect(job)}
|
||||
isSelected={selected.some(row => row.id === job.id)}
|
||||
|
||||
@ -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) : ''}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={t`Actions`}>
|
||||
<ActionItem
|
||||
visible={
|
||||
['pending', 'waiting', 'running'].includes(job.status) &&
|
||||
(job.type === 'system_job' ? isSuperUser : true)
|
||||
}
|
||||
>
|
||||
<JobCancelButton
|
||||
job={job}
|
||||
errorTitle={t`Job Cancel Error`}
|
||||
title={t`Cancel ${job.name}`}
|
||||
errorMessage={t`Failed to cancel ${job.name}`}
|
||||
showIconButton
|
||||
/>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={
|
||||
job.type !== 'system_job' &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import styled from 'styled-components';
|
||||
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import StatusIcon from '../../../components/StatusIcon';
|
||||
import JobCancelButton from '../../../components/JobCancelButton';
|
||||
import InventorySourceSyncButton from '../shared/InventorySourceSyncButton';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
|
||||
@ -91,12 +92,27 @@ function InventorySourceListItem({
|
||||
</Td>
|
||||
<Td dataLabel={t`Type`}>{label}</Td>
|
||||
<ActionsTd dataLabel={t`Actions`}>
|
||||
<ActionItem
|
||||
visible={source.summary_fields.user_capabilities.start}
|
||||
tooltip={t`Sync`}
|
||||
>
|
||||
<InventorySourceSyncButton source={source} />
|
||||
</ActionItem>
|
||||
{['running', 'pending', 'waiting'].includes(source?.status) ? (
|
||||
<ActionItem visible={source.summary_fields.user_capabilities.start}>
|
||||
<JobCancelButton
|
||||
job={{
|
||||
type: 'inventory_update',
|
||||
id: source.summary_fields.last_job.id,
|
||||
}}
|
||||
errorTitle={t`Inventory Source Sync Error`}
|
||||
errorMessage={t`Failed to cancel Inventory Source Sync`}
|
||||
title={t`Cancel Inventory Source Sync`}
|
||||
showIconButton
|
||||
/>
|
||||
</ActionItem>
|
||||
) : (
|
||||
<ActionItem
|
||||
visible={source.summary_fields.user_capabilities.start}
|
||||
tooltip={t`Sync`}
|
||||
>
|
||||
<InventorySourceSyncButton source={source} />
|
||||
</ActionItem>
|
||||
)}
|
||||
<ActionItem
|
||||
visible={source.summary_fields.user_capabilities.edit}
|
||||
tooltip={t`Edit`}
|
||||
|
||||
@ -3,11 +3,11 @@ import React, { useCallback } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { SyncIcon, MinusCircleIcon } from '@patternfly/react-icons';
|
||||
import { SyncIcon } from '@patternfly/react-icons';
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
|
||||
import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api';
|
||||
import { InventorySourcesAPI } from '../../../api';
|
||||
|
||||
function InventorySourceSyncButton({ source, icon }) {
|
||||
const {
|
||||
@ -25,60 +25,25 @@ function InventorySourceSyncButton({ source, icon }) {
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: cancelSyncLoading,
|
||||
error: cancelSyncError,
|
||||
request: cancelSyncProcess,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
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) ? (
|
||||
<Tooltip content={t`Cancel sync process`} position="top">
|
||||
<Button
|
||||
ouiaId={`${source}-cancel-sync-button`}
|
||||
isDisabled={cancelSyncLoading || startSyncLoading}
|
||||
aria-label={t`Cancel sync source`}
|
||||
variant={icon ? 'plain' : 'secondary'}
|
||||
onClick={cancelSyncProcess}
|
||||
>
|
||||
{icon ? <MinusCircleIcon /> : t`Cancel sync`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={t`Start sync process`} position="top">
|
||||
<Button
|
||||
ouiaId={`${source}-sync-button`}
|
||||
isDisabled={cancelSyncLoading || startSyncLoading}
|
||||
aria-label={t`Start sync source`}
|
||||
variant={icon ? 'plain' : 'secondary'}
|
||||
onClick={startSyncProcess}
|
||||
>
|
||||
{icon ? <SyncIcon /> : t`Sync`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t`Start sync process`} position="top">
|
||||
<Button
|
||||
ouiaId={`${source}-sync-button`}
|
||||
isDisabled={startSyncLoading}
|
||||
aria-label={t`Start sync source`}
|
||||
variant={icon ? 'plain' : 'secondary'}
|
||||
onClick={startSyncProcess}
|
||||
>
|
||||
{icon ? <SyncIcon /> : t`Sync`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{startError && (
|
||||
<AlertModal
|
||||
isOpen={startError}
|
||||
@ -90,17 +55,6 @@ function InventorySourceSyncButton({ source, icon }) {
|
||||
<ErrorDetail error={startError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{cancelError && (
|
||||
<AlertModal
|
||||
isOpen={cancelError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissCancelError}
|
||||
>
|
||||
{t`Failed to cancel inventory source sync.`}
|
||||
<ErrorDetail error={cancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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('<InventorySourceSyncButton />', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should render cancel sync button', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySourceSyncButton
|
||||
source={{ status: 'pending', ...source }}
|
||||
onSyncLoading={onSyncLoading}
|
||||
onFetchSources={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('MinusCircleIcon').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should start sync properly', async () => {
|
||||
InventorySourcesAPI.createSyncStart.mockResolvedValue({
|
||||
data: { status: 'pending' },
|
||||
@ -58,33 +47,6 @@ describe('<InventorySourceSyncButton />', () => {
|
||||
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(
|
||||
<InventorySourceSyncButton
|
||||
source={{ status: 'pending', ...source }}
|
||||
onSyncLoading={onSyncLoading}
|
||||
onFetchSources={() => {}}
|
||||
/>
|
||||
);
|
||||
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({
|
||||
|
||||
@ -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 }) {
|
||||
</LaunchButton>
|
||||
))}
|
||||
{isJobRunning(job.status) &&
|
||||
job?.summary_fields?.user_capabilities?.start && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={t`Cancel`}
|
||||
isDisabled={isCancelling}
|
||||
onClick={() => setShowCancelModal(true)}
|
||||
ouiaId="job-detail-cancel-button"
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
(job.type === 'system_job'
|
||||
? me.is_superuser
|
||||
: job?.summary_fields?.user_capabilities?.start) && (
|
||||
<JobCancelButton
|
||||
job={job}
|
||||
errorTitle={t`Job Cancel Error`}
|
||||
title={t`Cancel ${job.name}`}
|
||||
errorMessage={t`Failed to cancel ${job.name}`}
|
||||
/>
|
||||
)}
|
||||
{!isJobRunning(job.status) &&
|
||||
job?.summary_fields?.user_capabilities?.delete && (
|
||||
@ -417,49 +400,6 @@ function JobDetail({ job }) {
|
||||
</DeleteButton>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{showCancelModal && isJobRunning(job.status) && (
|
||||
<AlertModal
|
||||
isOpen={showCancelModal}
|
||||
variant="danger"
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
title={t`Cancel Job`}
|
||||
label={t`Cancel Job`}
|
||||
actions={[
|
||||
<Button
|
||||
id="cancel-job-confirm-button"
|
||||
key="delete"
|
||||
variant="danger"
|
||||
isDisabled={isCancelling}
|
||||
aria-label={t`Cancel job`}
|
||||
onClick={cancelJob}
|
||||
>
|
||||
{t`Cancel job`}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-job-return-button"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={t`Return`}
|
||||
onClick={() => setShowCancelModal(false)}
|
||||
>
|
||||
{t`Return`}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{t`Are you sure you want to submit the request to cancel this job?`}
|
||||
</AlertModal>
|
||||
)}
|
||||
{dismissableCancelError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableCancelError}
|
||||
variant="danger"
|
||||
onClose={dismissCancelError}
|
||||
title={t`Job Cancel Error`}
|
||||
label={t`Job Cancel Error`}
|
||||
>
|
||||
<ErrorDetail error={dismissableCancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<AlertModal
|
||||
isOpen={errorMsg}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../../testUtils/testUtils';
|
||||
import JobDetail from './JobDetail';
|
||||
@ -14,6 +15,10 @@ describe('<JobDetail />', () => {
|
||||
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('<JobDetail />', () => {
|
||||
});
|
||||
|
||||
test('DELETED is shown for required Job resources that have been deleted', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobDetail
|
||||
job={{
|
||||
...mockJobData,
|
||||
summary_fields: {
|
||||
...mockJobData.summary_fields,
|
||||
inventory: null,
|
||||
project: null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const newMockData = {
|
||||
...mockJobData,
|
||||
summary_fields: {
|
||||
...mockJobData.summary_fields,
|
||||
inventory: null,
|
||||
project: null,
|
||||
},
|
||||
};
|
||||
wrapper = mountWithContexts(<JobDetail job={newMockData} />);
|
||||
const detail = wrapper.find('JobDetail');
|
||||
async function assertMissingDetail(label) {
|
||||
expect(detail.length).toBe(1);
|
||||
@ -178,4 +180,128 @@ describe('<JobDetail />', () => {
|
||||
);
|
||||
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(
|
||||
<JobDetail
|
||||
job={{
|
||||
...mockJobData,
|
||||
status: 'pending',
|
||||
type: 'system_job',
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<JobDetail
|
||||
job={{
|
||||
...mockJobData,
|
||||
status: 'success',
|
||||
type: 'project_update',
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<JobDetail
|
||||
job={{
|
||||
...mockJobData,
|
||||
status: 'pending',
|
||||
type: 'system_job',
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<JobDetail
|
||||
job={{
|
||||
...mockJobData,
|
||||
status: 'pending',
|
||||
type: 'project_update',
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
},
|
||||
config: {
|
||||
me: {
|
||||
is_superuser: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Button[aria-label="Cancel Demo Job Template"]')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
<Wrapper>
|
||||
@ -131,43 +128,53 @@ const OutputToolbar = ({
|
||||
<Badge isRead>{toHHMMSS(job.elapsed)}</Badge>
|
||||
</Tooltip>
|
||||
</BadgeGroup>
|
||||
|
||||
{job.type !== 'system_job' &&
|
||||
job.summary_fields.user_capabilities?.start && (
|
||||
<Tooltip
|
||||
content={
|
||||
job.status === 'failed' && job.type === 'job'
|
||||
? t`Relaunch using host parameters`
|
||||
: t`Relaunch Job`
|
||||
}
|
||||
>
|
||||
{job.status === 'failed' && job.type === 'job' ? (
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch, isLaunching }) => (
|
||||
<ReLaunchDropDown
|
||||
handleRelaunch={handleRelaunch}
|
||||
ouiaId="job-output-relaunch-dropdown"
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
)}
|
||||
</LaunchButton>
|
||||
) : (
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch, isLaunching }) => (
|
||||
<Button
|
||||
ouiaId="job-output-relaunch-button"
|
||||
variant="plain"
|
||||
onClick={handleRelaunch}
|
||||
aria-label={t`Relaunch`}
|
||||
isDisabled={isLaunching}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
{['pending', 'waiting', 'running'].includes(jobStatus) &&
|
||||
(job.type === 'system_job'
|
||||
? me.is_superuser
|
||||
: job?.summary_fields?.user_capabilities?.start) && (
|
||||
<JobCancelButton
|
||||
job={job}
|
||||
errorTitle={t`Job Cancel Error`}
|
||||
title={t`Cancel ${job.name}`}
|
||||
errorMessage={t`Failed to cancel ${job.name}`}
|
||||
showIconButton
|
||||
/>
|
||||
)}
|
||||
{job.summary_fields.user_capabilities?.start && (
|
||||
<Tooltip
|
||||
content={
|
||||
job.status === 'failed' && job.type === 'job'
|
||||
? t`Relaunch using host parameters`
|
||||
: t`Relaunch Job`
|
||||
}
|
||||
>
|
||||
{job.status === 'failed' && job.type === 'job' ? (
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch, isLaunching }) => (
|
||||
<ReLaunchDropDown
|
||||
handleRelaunch={handleRelaunch}
|
||||
ouiaId="job-output-relaunch-dropdown"
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
)}
|
||||
</LaunchButton>
|
||||
) : (
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch, isLaunching }) => (
|
||||
<Button
|
||||
ouiaId="job-output-relaunch-button"
|
||||
variant="plain"
|
||||
onClick={handleRelaunch}
|
||||
aria-label={t`Relaunch`}
|
||||
isDisabled={isLaunching}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{job.related?.stdout && (
|
||||
<Tooltip content={t`Download Output`}>
|
||||
@ -182,19 +189,6 @@ const OutputToolbar = ({
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{job.summary_fields.user_capabilities.start &&
|
||||
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
||||
<Tooltip content={t`Cancel Job`}>
|
||||
<Button
|
||||
ouiaId="job-output-cancel-button"
|
||||
variant="plain"
|
||||
aria-label={t`Cancel Job`}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{job.summary_fields.user_capabilities.delete &&
|
||||
['new', 'successful', 'failed', 'error', 'canceled'].includes(
|
||||
jobStatus
|
||||
|
||||
@ -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`}
|
||||
</Button>
|
||||
)}
|
||||
{summary_fields.user_capabilities?.start && (
|
||||
<ProjectSyncButton
|
||||
projectId={project.id}
|
||||
lastJobStatus={job && job.status}
|
||||
/>
|
||||
)}
|
||||
{summary_fields.user_capabilities?.start &&
|
||||
(['running', 'pending', 'waiting'].includes(job?.status) ? (
|
||||
<JobCancelButton
|
||||
job={{ id: job.id, type: 'project_update' }}
|
||||
errorTitle={t`Project Sync Error`}
|
||||
title={t`Cancel Project Sync`}
|
||||
errorMessage={t`Failed to cancel Project Sync`}
|
||||
buttonText={t`Cancel Sync`}
|
||||
/>
|
||||
) : (
|
||||
<ProjectSyncButton
|
||||
projectId={project.id}
|
||||
lastJobStatus={job && job.status}
|
||||
/>
|
||||
))}
|
||||
{summary_fields.user_capabilities?.delete && (
|
||||
<DeleteButton
|
||||
name={name}
|
||||
modalTitle={t`Delete Project`}
|
||||
onConfirm={deleteProject}
|
||||
isDisabled={isLoading}
|
||||
isDisabled={isLoading || job?.status === 'running'}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={t`This project is currently being used by other resources. Are you sure you want to delete it?`}
|
||||
>
|
||||
@ -218,7 +228,6 @@ function ProjectDetail({ project }) {
|
||||
</DeleteButton>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
|
||||
@ -26,6 +26,7 @@ import { toTitleCase } from '../../../util/strings';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
||||
import { Project } from '../../../types';
|
||||
import JobCancelButton from '../../../components/JobCancelButton';
|
||||
|
||||
const Label = styled.span`
|
||||
color: var(--pf-global--disabled-color--100);
|
||||
@ -168,15 +169,29 @@ function ProjectListItem({
|
||||
/>
|
||||
</Td>
|
||||
<ActionsTd dataLabel={t`Actions`}>
|
||||
<ActionItem
|
||||
visible={project.summary_fields.user_capabilities.start}
|
||||
tooltip={t`Sync Project`}
|
||||
>
|
||||
<ProjectSyncButton
|
||||
projectId={project.id}
|
||||
lastJobStatus={job && job.status}
|
||||
/>
|
||||
</ActionItem>
|
||||
{['running', 'pending', 'waiting'].includes(job?.status) ? (
|
||||
<ActionItem
|
||||
visible={project.summary_fields.user_capabilities.start}
|
||||
>
|
||||
<JobCancelButton
|
||||
job={{ id: job.id, type: 'project_update' }}
|
||||
errorTitle={t`Project Sync Error`}
|
||||
title={t`Cancel Project Sync`}
|
||||
showIconButton
|
||||
errorMessage={t`Failed to cancel Project Sync`}
|
||||
/>
|
||||
</ActionItem>
|
||||
) : (
|
||||
<ActionItem
|
||||
visible={project.summary_fields.user_capabilities.start}
|
||||
tooltip={t`Sync Project`}
|
||||
>
|
||||
<ProjectSyncButton
|
||||
projectId={project.id}
|
||||
lastJobStatus={job && job.status}
|
||||
/>
|
||||
</ActionItem>
|
||||
)}
|
||||
<ActionItem
|
||||
visible={project.summary_fields.user_capabilities.edit}
|
||||
tooltip={t`Edit Project`}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user