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
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, useDeleteItems,
useDismissableError, useDismissableError,
} from '../../util/useRequest'; } from '../../util/useRequest';
import { useConfig } from '../../contexts/Config';
import { isJobRunning, getJobModel } from '../../util/jobs'; import { isJobRunning, getJobModel } from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem'; import JobListItem from './JobListItem';
@@ -32,6 +34,8 @@ function JobList({ defaultParams, showTypeColumn = false }) {
['id', 'page', 'page_size'] ['id', 'page', 'page_size']
); );
const { me } = useConfig();
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const location = useLocation(); const location = useLocation();
const { const {
@@ -261,6 +265,7 @@ function JobList({ defaultParams, showTypeColumn = false }) {
<JobListItem <JobListItem
key={job.id} key={job.id}
job={job} job={job}
isSuperUser={me?.is_superuser}
showTypeColumn={showTypeColumn} showTypeColumn={showTypeColumn}
onSelect={() => handleSelect(job)} onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)} isSelected={selected.some(row => row.id === job.id)}

View File

@@ -15,6 +15,7 @@ import CredentialChip from '../CredentialChip';
import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../ExecutionEnvironmentDetail';
import { formatDateString } from '../../util/dates'; import { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
import JobCancelButton from '../JobCancelButton';
const Dash = styled.span``; const Dash = styled.span``;
function JobListItem({ function JobListItem({
@@ -23,6 +24,7 @@ function JobListItem({
isSelected, isSelected,
onSelect, onSelect,
showTypeColumn = false, showTypeColumn = false,
isSuperUser = false,
}) { }) {
const labelId = `check-action-${job.id}`; const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -83,6 +85,20 @@ function JobListItem({
{job.finished ? formatDateString(job.finished) : ''} {job.finished ? formatDateString(job.finished) : ''}
</Td> </Td>
<ActionsTd dataLabel={t`Actions`}> <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 <ActionItem
visible={ visible={
job.type !== 'system_job' && 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 { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import StatusIcon from '../../../components/StatusIcon'; import StatusIcon from '../../../components/StatusIcon';
import JobCancelButton from '../../../components/JobCancelButton';
import InventorySourceSyncButton from '../shared/InventorySourceSyncButton'; import InventorySourceSyncButton from '../shared/InventorySourceSyncButton';
import { formatDateString } from '../../../util/dates'; import { formatDateString } from '../../../util/dates';
@@ -91,12 +92,27 @@ function InventorySourceListItem({
</Td> </Td>
<Td dataLabel={t`Type`}>{label}</Td> <Td dataLabel={t`Type`}>{label}</Td>
<ActionsTd dataLabel={t`Actions`}> <ActionsTd dataLabel={t`Actions`}>
<ActionItem {['running', 'pending', 'waiting'].includes(source?.status) ? (
visible={source.summary_fields.user_capabilities.start} <ActionItem visible={source.summary_fields.user_capabilities.start}>
tooltip={t`Sync`} <JobCancelButton
> job={{
<InventorySourceSyncButton source={source} /> type: 'inventory_update',
</ActionItem> 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 <ActionItem
visible={source.summary_fields.user_capabilities.edit} visible={source.summary_fields.user_capabilities.edit}
tooltip={t`Edit`} tooltip={t`Edit`}

View File

@@ -3,11 +3,11 @@ import React, { useCallback } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Tooltip } from '@patternfly/react-core'; 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 useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal/AlertModal'; import AlertModal from '../../../components/AlertModal/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api'; import { InventorySourcesAPI } from '../../../api';
function InventorySourceSyncButton({ source, icon }) { function InventorySourceSyncButton({ source, icon }) {
const { 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 { const {
error: startError, error: startError,
dismissError: dismissStartError, dismissError: dismissStartError,
} = useDismissableError(startSyncError); } = useDismissableError(startSyncError);
const {
error: cancelError,
dismissError: dismissCancelError,
} = useDismissableError(cancelSyncError);
return ( return (
<> <>
{['running', 'pending', 'updating'].includes(source.status) ? ( <Tooltip content={t`Start sync process`} position="top">
<Tooltip content={t`Cancel sync process`} position="top"> <Button
<Button ouiaId={`${source}-sync-button`}
ouiaId={`${source}-cancel-sync-button`} isDisabled={startSyncLoading}
isDisabled={cancelSyncLoading || startSyncLoading} aria-label={t`Start sync source`}
aria-label={t`Cancel sync source`} variant={icon ? 'plain' : 'secondary'}
variant={icon ? 'plain' : 'secondary'} onClick={startSyncProcess}
onClick={cancelSyncProcess} >
> {icon ? <SyncIcon /> : t`Sync`}
{icon ? <MinusCircleIcon /> : t`Cancel sync`} </Button>
</Button> </Tooltip>
</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>
)}
{startError && ( {startError && (
<AlertModal <AlertModal
isOpen={startError} isOpen={startError}
@@ -90,17 +55,6 @@ function InventorySourceSyncButton({ source, icon }) {
<ErrorDetail error={startError} /> <ErrorDetail error={startError} />
</AlertModal> </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 React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api'; import { InventorySourcesAPI } from '../../../api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import InventorySourceSyncButton from './InventorySourceSyncButton'; import InventorySourceSyncButton from './InventorySourceSyncButton';
@@ -36,17 +36,6 @@ describe('<InventorySourceSyncButton />', () => {
).toBe(false); ).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 () => { test('should start sync properly', async () => {
InventorySourcesAPI.createSyncStart.mockResolvedValue({ InventorySourcesAPI.createSyncStart.mockResolvedValue({
data: { status: 'pending' }, data: { status: 'pending' },
@@ -58,33 +47,6 @@ describe('<InventorySourceSyncButton />', () => {
expect(InventorySourcesAPI.createSyncStart).toBeCalledWith(1); 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 () => { test('should throw error on sync start properly', async () => {
InventorySourcesAPI.createSyncStart.mockRejectedValueOnce( InventorySourcesAPI.createSyncStart.mockRejectedValueOnce(
new Error({ new Error({

View File

@@ -1,11 +1,12 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { useCallback, useState } from 'react'; import React, { useState } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Chip } from '@patternfly/react-core'; import { Button, Chip } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { useConfig } from '../../../contexts/Config';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import { import {
DetailList, DetailList,
@@ -24,10 +25,10 @@ import {
ReLaunchDropDown, ReLaunchDropDown,
} from '../../../components/LaunchButton'; } from '../../../components/LaunchButton';
import StatusIcon from '../../../components/StatusIcon'; import StatusIcon from '../../../components/StatusIcon';
import JobCancelButton from '../../../components/JobCancelButton';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import { getJobModel, isJobRunning } from '../../../util/jobs'; import { getJobModel, isJobRunning } from '../../../util/jobs';
import { toTitleCase } from '../../../util/strings'; import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { formatDateString } from '../../../util/dates'; import { formatDateString } from '../../../util/dates';
import { Job } from '../../../types'; import { Job } from '../../../types';
@@ -53,6 +54,7 @@ const VERBOSITY = {
}; };
function JobDetail({ job }) { function JobDetail({ job }) {
const { me } = useConfig();
const { const {
created_by, created_by,
credential, credential,
@@ -72,24 +74,6 @@ function JobDetail({ job }) {
const [errorMsg, setErrorMsg] = useState(); const [errorMsg, setErrorMsg] = useState();
const history = useHistory(); 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 = { const jobTypes = {
project_update: t`Source Control Update`, project_update: t`Source Control Update`,
inventory_update: t`Inventory Sync`, inventory_update: t`Inventory Sync`,
@@ -394,16 +378,15 @@ function JobDetail({ job }) {
</LaunchButton> </LaunchButton>
))} ))}
{isJobRunning(job.status) && {isJobRunning(job.status) &&
job?.summary_fields?.user_capabilities?.start && ( (job.type === 'system_job'
<Button ? me.is_superuser
variant="secondary" : job?.summary_fields?.user_capabilities?.start) && (
aria-label={t`Cancel`} <JobCancelButton
isDisabled={isCancelling} job={job}
onClick={() => setShowCancelModal(true)} errorTitle={t`Job Cancel Error`}
ouiaId="job-detail-cancel-button" title={t`Cancel ${job.name}`}
> errorMessage={t`Failed to cancel ${job.name}`}
{t`Cancel`} />
</Button>
)} )}
{!isJobRunning(job.status) && {!isJobRunning(job.status) &&
job?.summary_fields?.user_capabilities?.delete && ( job?.summary_fields?.user_capabilities?.delete && (
@@ -417,49 +400,6 @@ function JobDetail({ job }) {
</DeleteButton> </DeleteButton>
)} )}
</CardActionsRow> </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 && ( {errorMsg && (
<AlertModal <AlertModal
isOpen={errorMsg} isOpen={errorMsg}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { sleep } from '../../../../testUtils/testUtils'; import { sleep } from '../../../../testUtils/testUtils';
import JobDetail from './JobDetail'; 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}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
} }
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should display details', () => { test('should display details', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
@@ -145,18 +150,15 @@ describe('<JobDetail />', () => {
}); });
test('DELETED is shown for required Job resources that have been deleted', () => { test('DELETED is shown for required Job resources that have been deleted', () => {
wrapper = mountWithContexts( const newMockData = {
<JobDetail ...mockJobData,
job={{ summary_fields: {
...mockJobData, ...mockJobData.summary_fields,
summary_fields: { inventory: null,
...mockJobData.summary_fields, project: null,
inventory: null, },
project: null, };
}, wrapper = mountWithContexts(<JobDetail job={newMockData} />);
}}
/>
);
const detail = wrapper.find('JobDetail'); const detail = wrapper.find('JobDetail');
async function assertMissingDetail(label) { async function assertMissingDetail(label) {
expect(detail.length).toBe(1); expect(detail.length).toBe(1);
@@ -178,4 +180,128 @@ describe('<JobDetail />', () => {
); );
assertDetail('Job Type', 'Playbook Check'); 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 { t } from '@lingui/macro';
import { bool, shape, func } from 'prop-types'; import { bool, shape, func } from 'prop-types';
import { import {
MinusCircleIcon,
DownloadIcon, DownloadIcon,
RocketIcon, RocketIcon,
TrashAltIcon, TrashAltIcon,
@@ -15,6 +14,9 @@ import {
LaunchButton, LaunchButton,
ReLaunchDropDown, ReLaunchDropDown,
} from '../../../../components/LaunchButton'; } from '../../../../components/LaunchButton';
import { useConfig } from '../../../../contexts/Config';
import JobCancelButton from '../../../../components/JobCancelButton';
const BadgeGroup = styled.div` const BadgeGroup = styled.div`
margin-left: 20px; margin-left: 20px;
@@ -62,13 +64,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update', 'inventory_update',
]; ];
const OutputToolbar = ({ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
job,
onDelete,
onCancel,
isDeleteDisabled,
jobStatus,
}) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type); const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count; const playCount = job?.playbook_counts?.play_count;
@@ -79,6 +75,7 @@ const OutputToolbar = ({
(sum, key) => sum + job?.host_status_counts[key], (sum, key) => sum + job?.host_status_counts[key],
0 0
); );
const { me } = useConfig();
return ( return (
<Wrapper> <Wrapper>
@@ -131,43 +128,53 @@ const OutputToolbar = ({
<Badge isRead>{toHHMMSS(job.elapsed)}</Badge> <Badge isRead>{toHHMMSS(job.elapsed)}</Badge>
</Tooltip> </Tooltip>
</BadgeGroup> </BadgeGroup>
{['pending', 'waiting', 'running'].includes(jobStatus) &&
{job.type !== 'system_job' && (job.type === 'system_job'
job.summary_fields.user_capabilities?.start && ( ? me.is_superuser
<Tooltip : job?.summary_fields?.user_capabilities?.start) && (
content={ <JobCancelButton
job.status === 'failed' && job.type === 'job' job={job}
? t`Relaunch using host parameters` errorTitle={t`Job Cancel Error`}
: t`Relaunch Job` title={t`Cancel ${job.name}`}
} errorMessage={t`Failed to cancel ${job.name}`}
> showIconButton
{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.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 && ( {job.related?.stdout && (
<Tooltip content={t`Download Output`}> <Tooltip content={t`Download Output`}>
@@ -182,19 +189,6 @@ const OutputToolbar = ({
</a> </a>
</Tooltip> </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 && {job.summary_fields.user_capabilities.delete &&
['new', 'successful', 'failed', 'error', 'canceled'].includes( ['new', 'successful', 'failed', 'error', 'canceled'].includes(
jobStatus jobStatus

View File

@@ -15,6 +15,7 @@ import {
UserDateDetail, UserDateDetail,
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import JobCancelButton from '../../../components/JobCancelButton';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import CredentialChip from '../../../components/CredentialChip'; import CredentialChip from '../../../components/CredentialChip';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
@@ -199,18 +200,27 @@ function ProjectDetail({ project }) {
{t`Edit`} {t`Edit`}
</Button> </Button>
)} )}
{summary_fields.user_capabilities?.start && ( {summary_fields.user_capabilities?.start &&
<ProjectSyncButton (['running', 'pending', 'waiting'].includes(job?.status) ? (
projectId={project.id} <JobCancelButton
lastJobStatus={job && job.status} 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 && ( {summary_fields.user_capabilities?.delete && (
<DeleteButton <DeleteButton
name={name} name={name}
modalTitle={t`Delete Project`} modalTitle={t`Delete Project`}
onConfirm={deleteProject} onConfirm={deleteProject}
isDisabled={isLoading} isDisabled={isLoading || job?.status === 'running'}
deleteDetailsRequests={deleteDetailsRequests} deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={t`This project is currently being used by other resources. Are you sure you want to delete it?`} 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> </DeleteButton>
)} )}
</CardActionsRow> </CardActionsRow>
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}

View File

@@ -26,6 +26,7 @@ import { toTitleCase } from '../../../util/strings';
import CopyButton from '../../../components/CopyButton'; import CopyButton from '../../../components/CopyButton';
import ProjectSyncButton from '../shared/ProjectSyncButton'; import ProjectSyncButton from '../shared/ProjectSyncButton';
import { Project } from '../../../types'; import { Project } from '../../../types';
import JobCancelButton from '../../../components/JobCancelButton';
const Label = styled.span` const Label = styled.span`
color: var(--pf-global--disabled-color--100); color: var(--pf-global--disabled-color--100);
@@ -168,15 +169,29 @@ function ProjectListItem({
/> />
</Td> </Td>
<ActionsTd dataLabel={t`Actions`}> <ActionsTd dataLabel={t`Actions`}>
<ActionItem {['running', 'pending', 'waiting'].includes(job?.status) ? (
visible={project.summary_fields.user_capabilities.start} <ActionItem
tooltip={t`Sync Project`} visible={project.summary_fields.user_capabilities.start}
> >
<ProjectSyncButton <JobCancelButton
projectId={project.id} job={{ id: job.id, type: 'project_update' }}
lastJobStatus={job && job.status} errorTitle={t`Project Sync Error`}
/> title={t`Cancel Project Sync`}
</ActionItem> 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 <ActionItem
visible={project.summary_fields.user_capabilities.edit} visible={project.summary_fields.user_capabilities.edit}
tooltip={t`Edit Project`} tooltip={t`Edit Project`}