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:
softwarefactory-project-zuul[bot] 2021-05-12 18:27:06 +00:00 committed by GitHub
commit 550a66553e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2539 additions and 1792 deletions

View 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;

View File

@ -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);
});
});

View File

@ -0,0 +1 @@
export { default } from './JobCancelButton';

View File

@ -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)}

View File

@ -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

View File

@ -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`}

View File

@ -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>
)}
</>
);
}

View File

@ -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({

View File

@ -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}

View File

@ -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);
});
});

View File

@ -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

View File

@ -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}

View File

@ -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`}