mirror of
https://github.com/ansible/awx.git
synced 2026-03-02 17:28:51 -03:30
Merge pull request #7993 from mabashian/7364-jobs-list-cancel
Adds cancel button to jobs list Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -1,13 +1,29 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import RelaunchMixin from '../mixins/Relaunch.mixin';
|
import RelaunchMixin from '../mixins/Relaunch.mixin';
|
||||||
|
|
||||||
const BASE_URLS = {
|
const getBaseURL = type => {
|
||||||
playbook: '/jobs/',
|
switch (type) {
|
||||||
project: '/project_updates/',
|
case 'playbook':
|
||||||
system: '/system_jobs/',
|
case 'job':
|
||||||
inventory: '/inventory_updates/',
|
return '/jobs/';
|
||||||
command: '/ad_hoc_commands/',
|
case 'project':
|
||||||
workflow: '/workflow_jobs/',
|
case 'project_update':
|
||||||
|
return '/project_updates/';
|
||||||
|
case 'system':
|
||||||
|
case 'system_job':
|
||||||
|
return '/system_jobs/';
|
||||||
|
case 'inventory':
|
||||||
|
case 'inventory_update':
|
||||||
|
return '/inventory_updates/';
|
||||||
|
case 'command':
|
||||||
|
case 'ad_hoc_command':
|
||||||
|
return '/ad_hoc_commands/';
|
||||||
|
case 'workflow':
|
||||||
|
case 'workflow_job':
|
||||||
|
return '/workflow_jobs/';
|
||||||
|
default:
|
||||||
|
throw new Error('Unable to find matching job type');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class Jobs extends RelaunchMixin(Base) {
|
class Jobs extends RelaunchMixin(Base) {
|
||||||
@@ -16,16 +32,20 @@ class Jobs extends RelaunchMixin(Base) {
|
|||||||
this.baseUrl = '/api/v2/jobs/';
|
this.baseUrl = '/api/v2/jobs/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel(id, type) {
|
||||||
|
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
||||||
|
}
|
||||||
|
|
||||||
readDetail(id, type) {
|
readDetail(id, type) {
|
||||||
return this.http.get(`/api/v2${BASE_URLS[type]}${id}/`);
|
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readEvents(id, type = 'playbook', params = {}) {
|
readEvents(id, type = 'playbook', params = {}) {
|
||||||
let endpoint;
|
let endpoint;
|
||||||
if (type === 'playbook') {
|
if (type === 'playbook') {
|
||||||
endpoint = `/api/v2${BASE_URLS[type]}${id}/job_events/`;
|
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
|
||||||
} else {
|
} else {
|
||||||
endpoint = `/api/v2${BASE_URLS[type]}${id}/events/`;
|
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
|
||||||
}
|
}
|
||||||
return this.http.get(endpoint, { params });
|
return this.http.get(endpoint, { params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Fragment, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -42,14 +42,21 @@ function DataListToolbar({
|
|||||||
pagination,
|
pagination,
|
||||||
}) {
|
}) {
|
||||||
const showExpandCollapse = onCompact && onExpand;
|
const showExpandCollapse = onCompact && onExpand;
|
||||||
const [kebabIsOpen, setKebabIsOpen] = useState(false);
|
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||||
const [advancedSearchShown, setAdvancedSearchShown] = useState(false);
|
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
|
||||||
|
const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false);
|
||||||
|
|
||||||
const onShowAdvancedSearch = shown => {
|
const onShowAdvancedSearch = shown => {
|
||||||
setAdvancedSearchShown(shown);
|
setIsAdvancedSearchShown(shown);
|
||||||
setKebabIsOpen(false);
|
setIsKebabOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isKebabModalOpen) {
|
||||||
|
setIsKebabOpen(false);
|
||||||
|
}
|
||||||
|
}, [isKebabModalOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar
|
<Toolbar
|
||||||
id={`${qsConfig.namespace}-list-toolbar`}
|
id={`${qsConfig.namespace}-list-toolbar`}
|
||||||
@@ -91,7 +98,7 @@ function DataListToolbar({
|
|||||||
</ToolbarToggleGroup>
|
</ToolbarToggleGroup>
|
||||||
{showExpandCollapse && (
|
{showExpandCollapse && (
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
<Fragment>
|
<>
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<ExpandCollapse
|
<ExpandCollapse
|
||||||
isCompact={isCompact}
|
isCompact={isCompact}
|
||||||
@@ -99,33 +106,42 @@ function DataListToolbar({
|
|||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
/>
|
/>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
</Fragment>
|
</>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
)}
|
)}
|
||||||
{advancedSearchShown && (
|
{isAdvancedSearchShown && (
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<Dropdown
|
<KebabifiedProvider
|
||||||
toggle={<KebabToggle onToggle={setKebabIsOpen} />}
|
value={{
|
||||||
isOpen={kebabIsOpen}
|
isKebabified: true,
|
||||||
isPlain
|
onKebabModalChange: setIsKebabModalOpen,
|
||||||
dropdownItems={additionalControls.map(control => {
|
}}
|
||||||
return (
|
>
|
||||||
<KebabifiedProvider value={{ isKebabified: true }}>
|
<Dropdown
|
||||||
{control}
|
toggle={
|
||||||
</KebabifiedProvider>
|
<KebabToggle
|
||||||
);
|
onToggle={isOpen => {
|
||||||
})}
|
if (!isKebabModalOpen) {
|
||||||
/>
|
setIsKebabOpen(isOpen);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isOpen={isKebabOpen}
|
||||||
|
isPlain
|
||||||
|
dropdownItems={additionalControls}
|
||||||
|
/>
|
||||||
|
</KebabifiedProvider>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
)}
|
)}
|
||||||
{!advancedSearchShown && (
|
{!isAdvancedSearchShown && (
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
{additionalControls.map(control => (
|
{additionalControls.map(control => (
|
||||||
<ToolbarItem key={control.key}>{control}</ToolbarItem>
|
<ToolbarItem key={control.key}>{control}</ToolbarItem>
|
||||||
))}
|
))}
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
)}
|
)}
|
||||||
{!advancedSearchShown && pagination && itemCount > 0 && (
|
{!isAdvancedSearchShown && pagination && itemCount > 0 && (
|
||||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||||
)}
|
)}
|
||||||
</ToolbarContent>
|
</ToolbarContent>
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import AlertModal from '../AlertModal';
|
|||||||
import DatalistToolbar from '../DataListToolbar';
|
import DatalistToolbar from '../DataListToolbar';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
|
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||||
import useRequest, { useDeleteItems } from '../../util/useRequest';
|
import useRequest, {
|
||||||
|
useDeleteItems,
|
||||||
|
useDismissableError,
|
||||||
|
} from '../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import JobListItem from './JobListItem';
|
import JobListItem from './JobListItem';
|
||||||
|
import JobListCancelButton from './JobListCancelButton';
|
||||||
import useWsJobs from './useWsJobs';
|
import useWsJobs from './useWsJobs';
|
||||||
import {
|
import {
|
||||||
AdHocCommandsAPI,
|
AdHocCommandsAPI,
|
||||||
@@ -88,6 +92,30 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
|
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
|
||||||
|
|
||||||
const isAllSelected = selected.length === jobs.length && selected.length > 0;
|
const isAllSelected = selected.length === jobs.length && selected.length > 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: cancelJobsError,
|
||||||
|
isLoading: isCancelLoading,
|
||||||
|
request: cancelJobs,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
return Promise.all(
|
||||||
|
selected.map(job => {
|
||||||
|
if (['new', 'pending', 'waiting', 'running'].includes(job.status)) {
|
||||||
|
return JobsAPI.cancel(job.id, job.type);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [selected]),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: cancelError,
|
||||||
|
dismissError: dismissCancelError,
|
||||||
|
} = useDismissableError(cancelJobsError);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading: isDeleteLoading,
|
isLoading: isDeleteLoading,
|
||||||
deleteItems: deleteJobs,
|
deleteItems: deleteJobs,
|
||||||
@@ -123,6 +151,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleJobCancel = async () => {
|
||||||
|
await cancelJobs();
|
||||||
|
setSelected([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleJobDelete = async () => {
|
const handleJobDelete = async () => {
|
||||||
await deleteJobs();
|
await deleteJobs();
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
@@ -145,7 +178,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
||||||
items={jobs}
|
items={jobs}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={i18n._(t`Jobs`)}
|
pluralizedItemName={i18n._(t`Jobs`)}
|
||||||
@@ -242,6 +275,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName="Jobs"
|
pluralizedItemName="Jobs"
|
||||||
/>,
|
/>,
|
||||||
|
<JobListCancelButton
|
||||||
|
key="cancel"
|
||||||
|
onCancel={handleJobCancel}
|
||||||
|
jobsToCancel={selected}
|
||||||
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -256,15 +294,28 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<AlertModal
|
{deletionError && (
|
||||||
isOpen={deletionError}
|
<AlertModal
|
||||||
variant="error"
|
isOpen
|
||||||
title={i18n._(t`Error!`)}
|
variant="error"
|
||||||
onClose={clearDeletionError}
|
title={i18n._(t`Error!`)}
|
||||||
>
|
onClose={clearDeletionError}
|
||||||
{i18n._(t`Failed to delete one or more jobs.`)}
|
>
|
||||||
<ErrorDetail error={deletionError} />
|
{i18n._(t`Failed to delete one or more jobs.`)}
|
||||||
</AlertModal>
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
{cancelError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={dismissCancelError}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to cancel one or more jobs.`)}
|
||||||
|
<ErrorDetail error={cancelError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/project_updates/1',
|
url: '/api/v2/project_updates/1',
|
||||||
name: 'job 1',
|
name: 'job 1',
|
||||||
type: 'project_update',
|
type: 'project_update',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/project_updates/1/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -35,6 +39,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/jobs/2',
|
url: '/api/v2/jobs/2',
|
||||||
name: 'job 2',
|
name: 'job 2',
|
||||||
type: 'job',
|
type: 'job',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/jobs/2/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -47,6 +55,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/inventory_updates/3',
|
url: '/api/v2/inventory_updates/3',
|
||||||
name: 'job 3',
|
name: 'job 3',
|
||||||
type: 'inventory_update',
|
type: 'inventory_update',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/inventory_updates/3/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -59,6 +71,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/workflow_jobs/4',
|
url: '/api/v2/workflow_jobs/4',
|
||||||
name: 'job 4',
|
name: 'job 4',
|
||||||
type: 'workflow_job',
|
type: 'workflow_job',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/workflow_jobs/4/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -71,6 +87,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/system_jobs/5',
|
url: '/api/v2/system_jobs/5',
|
||||||
name: 'job 5',
|
name: 'job 5',
|
||||||
type: 'system_job',
|
type: 'system_job',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/system_jobs/5/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -83,6 +103,10 @@ const mockResults = [
|
|||||||
url: '/api/v2/ad_hoc_commands/6',
|
url: '/api/v2/ad_hoc_commands/6',
|
||||||
name: 'job 6',
|
name: 'job 6',
|
||||||
type: 'ad_hoc_command',
|
type: 'ad_hoc_command',
|
||||||
|
status: 'running',
|
||||||
|
related: {
|
||||||
|
cancel: '/api/v2/ad_hoc_commands/6/cancel',
|
||||||
|
},
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -273,4 +297,72 @@ describe('<JobList />', () => {
|
|||||||
el => el.props().isOpen === true && el.props().title === 'Error!'
|
el => el.props().isOpen === true && el.props().title === 'Error!'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should send all corresponding delete API requests', async () => {
|
||||||
|
JobsAPI.cancel = jest.fn();
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<JobList />);
|
||||||
|
});
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper.find('JobListItem');
|
||||||
|
expect(
|
||||||
|
wrapper.find('JobListCancelButton').prop('jobsToCancel')
|
||||||
|
).toHaveLength(6);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('JobListCancelButton').invoke('onCancel')();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledTimes(6);
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(1, 'project_update');
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(2, 'job');
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(3, 'inventory_update');
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(4, 'workflow_job');
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(5, 'system_job');
|
||||||
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(6, 'ad_hoc_command');
|
||||||
|
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error is shown when job not successfully cancelled', async () => {
|
||||||
|
JobsAPI.cancel.mockImplementation(() => {
|
||||||
|
throw new Error({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/v2/jobs/2/cancel',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<JobList />);
|
||||||
|
});
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('JobListItem')
|
||||||
|
.at(1)
|
||||||
|
.invoke('onSelect')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('JobListCancelButton').invoke('onCancel')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal',
|
||||||
|
el => el.props().isOpen === true && el.props().title === 'Error!'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
156
awx/ui_next/src/components/JobList/JobListCancelButton.jsx
Normal file
156
awx/ui_next/src/components/JobList/JobListCancelButton.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { arrayOf, func } from 'prop-types';
|
||||||
|
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||||
|
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||||
|
import AlertModal from '../AlertModal';
|
||||||
|
import { Job } from '../../types';
|
||||||
|
|
||||||
|
function cannotCancel(job) {
|
||||||
|
return !job.summary_fields.user_capabilities.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobListCancelButton({ i18n, jobsToCancel, onCancel }) {
|
||||||
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const numJobsToCancel = jobsToCancel.length;
|
||||||
|
const zeroOrOneJobSelected = numJobsToCancel < 2;
|
||||||
|
|
||||||
|
const handleCancelJob = () => {
|
||||||
|
onCancel();
|
||||||
|
toggleModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleModal = () => {
|
||||||
|
setIsModalOpen(!isModalOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isKebabified) {
|
||||||
|
onKebabModalChange(isModalOpen);
|
||||||
|
}
|
||||||
|
}, [isKebabified, isModalOpen, onKebabModalChange]);
|
||||||
|
|
||||||
|
const renderTooltip = () => {
|
||||||
|
const jobsUnableToCancel = jobsToCancel
|
||||||
|
.filter(cannotCancel)
|
||||||
|
.map(job => job.name);
|
||||||
|
const numJobsUnableToCancel = jobsUnableToCancel.length;
|
||||||
|
if (numJobsUnableToCancel > 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{i18n._(
|
||||||
|
'{numJobsUnableToCancel, plural, one {You do not have permission to cancel the following job:} other {You do not have permission to cancel the following jobs:}}',
|
||||||
|
{
|
||||||
|
numJobsUnableToCancel,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{' '.concat(jobsUnableToCancel.join(', '))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (numJobsToCancel > 0) {
|
||||||
|
return i18n._(
|
||||||
|
'{numJobsToCancel, plural, one {Cancel selected job} other {Cancel selected jobs}}',
|
||||||
|
{
|
||||||
|
numJobsToCancel,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return i18n._(t`Select a job to cancel`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled =
|
||||||
|
jobsToCancel.length === 0 || jobsToCancel.some(cannotCancel);
|
||||||
|
|
||||||
|
const cancelJobText = i18n._(
|
||||||
|
'{zeroOrOneJobSelected, plural, one {Cancel job} other {Cancel jobs}}',
|
||||||
|
{
|
||||||
|
zeroOrOneJobSelected,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isKebabified ? (
|
||||||
|
<DropdownItem
|
||||||
|
key="cancel-job"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
component="button"
|
||||||
|
onClick={toggleModal}
|
||||||
|
>
|
||||||
|
{cancelJobText}
|
||||||
|
</DropdownItem>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={cancelJobText}
|
||||||
|
onClick={toggleModal}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{cancelJobText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isModalOpen && (
|
||||||
|
<AlertModal
|
||||||
|
variant="danger"
|
||||||
|
title={cancelJobText}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={toggleModal}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="cancel-job-confirm-button"
|
||||||
|
key="delete"
|
||||||
|
variant="danger"
|
||||||
|
aria-label={cancelJobText}
|
||||||
|
onClick={handleCancelJob}
|
||||||
|
>
|
||||||
|
{cancelJobText}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="cancel-job-return-button"
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Return`)}
|
||||||
|
onClick={toggleModal}
|
||||||
|
>
|
||||||
|
{i18n._(t`Return`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{i18n._(
|
||||||
|
'{numJobsToCancel, plural, one {This action will cancel the following job:} other {This action will cancel the following jobs:}}',
|
||||||
|
{
|
||||||
|
numJobsToCancel,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{jobsToCancel.map(job => (
|
||||||
|
<span key={job.id}>
|
||||||
|
<strong>{job.name}</strong>
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JobListCancelButton.propTypes = {
|
||||||
|
jobsToCancel: arrayOf(Job),
|
||||||
|
onCancel: func,
|
||||||
|
};
|
||||||
|
|
||||||
|
JobListCancelButton.defaultProps = {
|
||||||
|
jobsToCancel: [],
|
||||||
|
onCancel: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(JobListCancelButton);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import JobListCancelButton from './JobListCancelButton';
|
||||||
|
|
||||||
|
describe('<JobListCancelButton />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
test('should be disabled when no rows are selected', () => {
|
||||||
|
wrapper = mountWithContexts(<JobListCancelButton jobsToCancel={[]} />);
|
||||||
|
expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Tooltip').props().content).toBe(
|
||||||
|
'Select a job to cancel'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should be disabled when user does not have permissions to cancel selected job', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<JobListCancelButton
|
||||||
|
jobsToCancel={[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'some job',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: false,
|
||||||
|
start: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should be enabled when user does have permission to cancel selected job', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<JobListCancelButton
|
||||||
|
jobsToCancel={[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'some job',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: true,
|
||||||
|
start: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('JobListCancelButton button').props().disabled).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('modal functions as expected', () => {
|
||||||
|
const onCancel = jest.fn();
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<JobListCancelButton
|
||||||
|
jobsToCancel={[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'some job',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: true,
|
||||||
|
start: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('AlertModal').length).toBe(0);
|
||||||
|
wrapper.find('JobListCancelButton button').simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('AlertModal').length).toBe(1);
|
||||||
|
wrapper.find('button#cancel-job-return-button').simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('AlertModal').length).toBe(0);
|
||||||
|
expect(wrapper.find('AlertModal').length).toBe(0);
|
||||||
|
wrapper.find('JobListCancelButton button').simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('AlertModal').length).toBe(1);
|
||||||
|
wrapper.find('button#cancel-job-confirm-button').simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
func,
|
func,
|
||||||
bool,
|
bool,
|
||||||
@@ -12,7 +12,7 @@ import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import { Kebabified } from '../../contexts/Kebabified';
|
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||||
|
|
||||||
const requireNameOrUsername = props => {
|
const requireNameOrUsername = props => {
|
||||||
const { name, username } = props;
|
const { name, username } = props;
|
||||||
@@ -59,53 +59,32 @@ function cannotDelete(item) {
|
|||||||
return !item.summary_fields.user_capabilities.delete;
|
return !item.summary_fields.user_capabilities.delete;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToolbarDeleteButton extends React.Component {
|
function ToolbarDeleteButton({
|
||||||
static propTypes = {
|
itemsToDelete,
|
||||||
onDelete: func.isRequired,
|
pluralizedItemName,
|
||||||
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
errorMessage,
|
||||||
pluralizedItemName: string,
|
onDelete,
|
||||||
errorMessage: string,
|
i18n,
|
||||||
};
|
}) {
|
||||||
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
static defaultProps = {
|
const handleDelete = () => {
|
||||||
pluralizedItemName: 'Items',
|
|
||||||
errorMessage: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isModalOpen: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleConfirmDelete = this.handleConfirmDelete.bind(this);
|
|
||||||
this.handleCancelDelete = this.handleCancelDelete.bind(this);
|
|
||||||
this.handleDelete = this.handleDelete.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleConfirmDelete() {
|
|
||||||
this.setState({ isModalOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancelDelete() {
|
|
||||||
this.setState({ isModalOpen: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDelete() {
|
|
||||||
const { onDelete } = this.props;
|
|
||||||
onDelete();
|
onDelete();
|
||||||
this.setState({ isModalOpen: false });
|
toggleModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
renderTooltip() {
|
const toggleModal = () => {
|
||||||
const {
|
setIsModalOpen(!isModalOpen);
|
||||||
itemsToDelete,
|
};
|
||||||
pluralizedItemName,
|
|
||||||
errorMessage,
|
|
||||||
i18n,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isKebabified) {
|
||||||
|
onKebabModalChange(isModalOpen);
|
||||||
|
}
|
||||||
|
}, [isKebabified, isModalOpen, onKebabModalChange]);
|
||||||
|
|
||||||
|
const renderTooltip = () => {
|
||||||
const itemsUnableToDelete = itemsToDelete
|
const itemsUnableToDelete = itemsToDelete
|
||||||
.filter(cannotDelete)
|
.filter(cannotDelete)
|
||||||
.map(item => item.name)
|
.map(item => item.name)
|
||||||
@@ -125,85 +104,89 @@ class ToolbarDeleteButton extends React.Component {
|
|||||||
return i18n._(t`Delete`);
|
return i18n._(t`Delete`);
|
||||||
}
|
}
|
||||||
return i18n._(t`Select a row to delete`);
|
return i18n._(t`Select a row to delete`);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`);
|
||||||
const { itemsToDelete, pluralizedItemName, i18n } = this.props;
|
|
||||||
const { isModalOpen } = this.state;
|
|
||||||
const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`);
|
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
||||||
|
|
||||||
// NOTE: Once PF supports tooltips on disabled elements,
|
// NOTE: Once PF supports tooltips on disabled elements,
|
||||||
// we can delete the extra <div> around the <DeleteButton> below.
|
// we can delete the extra <div> around the <DeleteButton> below.
|
||||||
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
||||||
return (
|
return (
|
||||||
<Kebabified>
|
<>
|
||||||
{({ isKebabified }) => (
|
{isKebabified ? (
|
||||||
<Fragment>
|
<DropdownItem
|
||||||
{isKebabified ? (
|
key="add"
|
||||||
<DropdownItem
|
isDisabled={isDisabled}
|
||||||
key="add"
|
component="button"
|
||||||
isDisabled={isDisabled}
|
onClick={toggleModal}
|
||||||
component="Button"
|
>
|
||||||
onClick={this.handleConfirmDelete}
|
{i18n._(t`Delete`)}
|
||||||
>
|
</DropdownItem>
|
||||||
{i18n._(t`Delete`)}
|
) : (
|
||||||
</DropdownItem>
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
) : (
|
<div>
|
||||||
<Tooltip content={this.renderTooltip()} position="top">
|
<Button
|
||||||
<div>
|
variant="secondary"
|
||||||
<Button
|
aria-label={i18n._(t`Delete`)}
|
||||||
variant="secondary"
|
onClick={toggleModal}
|
||||||
aria-label={i18n._(t`Delete`)}
|
isDisabled={isDisabled}
|
||||||
onClick={this.handleConfirmDelete}
|
>
|
||||||
isDisabled={isDisabled}
|
{i18n._(t`Delete`)}
|
||||||
>
|
</Button>
|
||||||
{i18n._(t`Delete`)}
|
</div>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</div>
|
)}
|
||||||
</Tooltip>
|
{isModalOpen && (
|
||||||
)}
|
<AlertModal
|
||||||
{isModalOpen && (
|
variant="danger"
|
||||||
<AlertModal
|
title={modalTitle}
|
||||||
variant="danger"
|
isOpen={isModalOpen}
|
||||||
title={modalTitle}
|
onClose={toggleModal}
|
||||||
isOpen={isModalOpen}
|
actions={[
|
||||||
onClose={this.handleCancelDelete}
|
<Button
|
||||||
actions={[
|
key="delete"
|
||||||
<Button
|
variant="danger"
|
||||||
key="delete"
|
aria-label={i18n._(t`confirm delete`)}
|
||||||
variant="danger"
|
onClick={handleDelete}
|
||||||
aria-label={i18n._(t`confirm delete`)}
|
>
|
||||||
onClick={this.handleDelete}
|
{i18n._(t`Delete`)}
|
||||||
>
|
</Button>,
|
||||||
{i18n._(t`Delete`)}
|
<Button
|
||||||
</Button>,
|
key="cancel"
|
||||||
<Button
|
variant="secondary"
|
||||||
key="cancel"
|
aria-label={i18n._(t`cancel delete`)}
|
||||||
variant="secondary"
|
onClick={toggleModal}
|
||||||
aria-label={i18n._(t`cancel delete`)}
|
>
|
||||||
onClick={this.handleCancelDelete}
|
{i18n._(t`Cancel`)}
|
||||||
>
|
</Button>,
|
||||||
{i18n._(t`Cancel`)}
|
]}
|
||||||
</Button>,
|
>
|
||||||
]}
|
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||||
>
|
{itemsToDelete.map(item => (
|
||||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
<span key={item.id}>
|
||||||
{itemsToDelete.map(item => (
|
<strong>{item.name || item.username}</strong>
|
||||||
<span key={item.id}>
|
<br />
|
||||||
<strong>{item.name || item.username}</strong>
|
</span>
|
||||||
<br />
|
))}
|
||||||
</span>
|
</AlertModal>
|
||||||
))}
|
)}
|
||||||
</AlertModal>
|
</>
|
||||||
)}
|
);
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</Kebabified>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolbarDeleteButton.propTypes = {
|
||||||
|
onDelete: func.isRequired,
|
||||||
|
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
||||||
|
pluralizedItemName: string,
|
||||||
|
errorMessage: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
ToolbarDeleteButton.defaultProps = {
|
||||||
|
pluralizedItemName: 'Items',
|
||||||
|
errorMessage: '',
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(ToolbarDeleteButton);
|
export default withI18n()(ToolbarDeleteButton);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ describe('<ToolbarDeleteButton />', () => {
|
|||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
||||||
);
|
);
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
wrapper.find('button').simulate('click');
|
wrapper.find('button').simulate('click');
|
||||||
expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(true);
|
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('Modal')).toHaveLength(1);
|
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -37,15 +37,14 @@ describe('<ToolbarDeleteButton />', () => {
|
|||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
|
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
|
||||||
);
|
);
|
||||||
wrapper.find('ToolbarDeleteButton').setState({ isModalOpen: true });
|
wrapper.find('button').simulate('click');
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
wrapper
|
wrapper
|
||||||
.find('ModalBoxFooter button[aria-label="confirm delete"]')
|
.find('ModalBoxFooter button[aria-label="confirm delete"]')
|
||||||
.simulate('click');
|
.simulate('click');
|
||||||
|
wrapper.update();
|
||||||
expect(onDelete).toHaveBeenCalled();
|
expect(onDelete).toHaveBeenCalled();
|
||||||
expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should disable button when no delete permissions', () => {
|
test('should disable button when no delete permissions', () => {
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ describe('<TemplateList />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
||||||
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await wrapper
|
await wrapper
|
||||||
@@ -302,7 +304,9 @@ describe('<TemplateList />', () => {
|
|||||||
summary_fields: { user_capabilities: { delete: true } },
|
summary_fields: { user_capabilities: { delete: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
||||||
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await wrapper
|
await wrapper
|
||||||
|
|||||||
Reference in New Issue
Block a user