mirror of
https://github.com/ansible/awx.git
synced 2026-02-25 06:56:00 -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 RelaunchMixin from '../mixins/Relaunch.mixin';
|
||||
|
||||
const BASE_URLS = {
|
||||
playbook: '/jobs/',
|
||||
project: '/project_updates/',
|
||||
system: '/system_jobs/',
|
||||
inventory: '/inventory_updates/',
|
||||
command: '/ad_hoc_commands/',
|
||||
workflow: '/workflow_jobs/',
|
||||
const getBaseURL = type => {
|
||||
switch (type) {
|
||||
case 'playbook':
|
||||
case 'job':
|
||||
return '/jobs/';
|
||||
case 'project':
|
||||
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) {
|
||||
@@ -16,16 +32,20 @@ class Jobs extends RelaunchMixin(Base) {
|
||||
this.baseUrl = '/api/v2/jobs/';
|
||||
}
|
||||
|
||||
cancel(id, type) {
|
||||
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
let endpoint;
|
||||
if (type === 'playbook') {
|
||||
endpoint = `/api/v2${BASE_URLS[type]}${id}/job_events/`;
|
||||
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
|
||||
} else {
|
||||
endpoint = `/api/v2${BASE_URLS[type]}${id}/events/`;
|
||||
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
|
||||
}
|
||||
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 { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
@@ -42,14 +42,21 @@ function DataListToolbar({
|
||||
pagination,
|
||||
}) {
|
||||
const showExpandCollapse = onCompact && onExpand;
|
||||
const [kebabIsOpen, setKebabIsOpen] = useState(false);
|
||||
const [advancedSearchShown, setAdvancedSearchShown] = useState(false);
|
||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
|
||||
const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false);
|
||||
|
||||
const onShowAdvancedSearch = shown => {
|
||||
setAdvancedSearchShown(shown);
|
||||
setKebabIsOpen(false);
|
||||
setIsAdvancedSearchShown(shown);
|
||||
setIsKebabOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isKebabModalOpen) {
|
||||
setIsKebabOpen(false);
|
||||
}
|
||||
}, [isKebabModalOpen]);
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
id={`${qsConfig.namespace}-list-toolbar`}
|
||||
@@ -91,7 +98,7 @@ function DataListToolbar({
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
<Fragment>
|
||||
<>
|
||||
<ToolbarItem>
|
||||
<ExpandCollapse
|
||||
isCompact={isCompact}
|
||||
@@ -99,33 +106,42 @@ function DataListToolbar({
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</Fragment>
|
||||
</>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
{advancedSearchShown && (
|
||||
{isAdvancedSearchShown && (
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
toggle={<KebabToggle onToggle={setKebabIsOpen} />}
|
||||
isOpen={kebabIsOpen}
|
||||
isPlain
|
||||
dropdownItems={additionalControls.map(control => {
|
||||
return (
|
||||
<KebabifiedProvider value={{ isKebabified: true }}>
|
||||
{control}
|
||||
</KebabifiedProvider>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
<KebabifiedProvider
|
||||
value={{
|
||||
isKebabified: true,
|
||||
onKebabModalChange: setIsKebabModalOpen,
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
toggle={
|
||||
<KebabToggle
|
||||
onToggle={isOpen => {
|
||||
if (!isKebabModalOpen) {
|
||||
setIsKebabOpen(isOpen);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isOpen={isKebabOpen}
|
||||
isPlain
|
||||
dropdownItems={additionalControls}
|
||||
/>
|
||||
</KebabifiedProvider>
|
||||
</ToolbarItem>
|
||||
)}
|
||||
{!advancedSearchShown && (
|
||||
{!isAdvancedSearchShown && (
|
||||
<ToolbarGroup>
|
||||
{additionalControls.map(control => (
|
||||
<ToolbarItem key={control.key}>{control}</ToolbarItem>
|
||||
))}
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
{!advancedSearchShown && pagination && itemCount > 0 && (
|
||||
{!isAdvancedSearchShown && pagination && itemCount > 0 && (
|
||||
<ToolbarItem variant="pagination">{pagination}</ToolbarItem>
|
||||
)}
|
||||
</ToolbarContent>
|
||||
|
||||
@@ -8,9 +8,13 @@ import AlertModal from '../AlertModal';
|
||||
import DatalistToolbar from '../DataListToolbar';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
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 JobListItem from './JobListItem';
|
||||
import JobListCancelButton from './JobListCancelButton';
|
||||
import useWsJobs from './useWsJobs';
|
||||
import {
|
||||
AdHocCommandsAPI,
|
||||
@@ -88,6 +92,30 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
|
||||
|
||||
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 {
|
||||
isLoading: isDeleteLoading,
|
||||
deleteItems: deleteJobs,
|
||||
@@ -123,6 +151,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
}
|
||||
);
|
||||
|
||||
const handleJobCancel = async () => {
|
||||
await cancelJobs();
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleJobDelete = async () => {
|
||||
await deleteJobs();
|
||||
setSelected([]);
|
||||
@@ -145,7 +178,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
||||
items={jobs}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Jobs`)}
|
||||
@@ -242,6 +275,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Jobs"
|
||||
/>,
|
||||
<JobListCancelButton
|
||||
key="cancel"
|
||||
onCancel={handleJobCancel}
|
||||
jobsToCancel={selected}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -256,15 +294,28 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more jobs.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more jobs.`)}
|
||||
<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',
|
||||
name: 'job 1',
|
||||
type: 'project_update',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/project_updates/1/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -35,6 +39,10 @@ const mockResults = [
|
||||
url: '/api/v2/jobs/2',
|
||||
name: 'job 2',
|
||||
type: 'job',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/jobs/2/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -47,6 +55,10 @@ const mockResults = [
|
||||
url: '/api/v2/inventory_updates/3',
|
||||
name: 'job 3',
|
||||
type: 'inventory_update',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/inventory_updates/3/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -59,6 +71,10 @@ const mockResults = [
|
||||
url: '/api/v2/workflow_jobs/4',
|
||||
name: 'job 4',
|
||||
type: 'workflow_job',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/workflow_jobs/4/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -71,6 +87,10 @@ const mockResults = [
|
||||
url: '/api/v2/system_jobs/5',
|
||||
name: 'job 5',
|
||||
type: 'system_job',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/system_jobs/5/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -83,6 +103,10 @@ const mockResults = [
|
||||
url: '/api/v2/ad_hoc_commands/6',
|
||||
name: 'job 6',
|
||||
type: 'ad_hoc_command',
|
||||
status: 'running',
|
||||
related: {
|
||||
cancel: '/api/v2/ad_hoc_commands/6/cancel',
|
||||
},
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
@@ -273,4 +297,72 @@ describe('<JobList />', () => {
|
||||
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 {
|
||||
func,
|
||||
bool,
|
||||
@@ -12,7 +12,7 @@ import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AlertModal from '../AlertModal';
|
||||
import { Kebabified } from '../../contexts/Kebabified';
|
||||
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||
|
||||
const requireNameOrUsername = props => {
|
||||
const { name, username } = props;
|
||||
@@ -59,53 +59,32 @@ function cannotDelete(item) {
|
||||
return !item.summary_fields.user_capabilities.delete;
|
||||
}
|
||||
|
||||
class ToolbarDeleteButton extends React.Component {
|
||||
static propTypes = {
|
||||
onDelete: func.isRequired,
|
||||
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
||||
pluralizedItemName: string,
|
||||
errorMessage: string,
|
||||
};
|
||||
function ToolbarDeleteButton({
|
||||
itemsToDelete,
|
||||
pluralizedItemName,
|
||||
errorMessage,
|
||||
onDelete,
|
||||
i18n,
|
||||
}) {
|
||||
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
static defaultProps = {
|
||||
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;
|
||||
const handleDelete = () => {
|
||||
onDelete();
|
||||
this.setState({ isModalOpen: false });
|
||||
}
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
renderTooltip() {
|
||||
const {
|
||||
itemsToDelete,
|
||||
pluralizedItemName,
|
||||
errorMessage,
|
||||
i18n,
|
||||
} = this.props;
|
||||
const toggleModal = () => {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isKebabified) {
|
||||
onKebabModalChange(isModalOpen);
|
||||
}
|
||||
}, [isKebabified, isModalOpen, onKebabModalChange]);
|
||||
|
||||
const renderTooltip = () => {
|
||||
const itemsUnableToDelete = itemsToDelete
|
||||
.filter(cannotDelete)
|
||||
.map(item => item.name)
|
||||
@@ -125,85 +104,89 @@ class ToolbarDeleteButton extends React.Component {
|
||||
return i18n._(t`Delete`);
|
||||
}
|
||||
return i18n._(t`Select a row to delete`);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { itemsToDelete, pluralizedItemName, i18n } = this.props;
|
||||
const { isModalOpen } = this.state;
|
||||
const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`);
|
||||
const modalTitle = i18n._(t`Delete ${pluralizedItemName}?`);
|
||||
|
||||
const isDisabled =
|
||||
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
||||
const isDisabled =
|
||||
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
||||
|
||||
// NOTE: Once PF supports tooltips on disabled elements,
|
||||
// we can delete the extra <div> around the <DeleteButton> below.
|
||||
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
||||
return (
|
||||
<Kebabified>
|
||||
{({ isKebabified }) => (
|
||||
<Fragment>
|
||||
{isKebabified ? (
|
||||
<DropdownItem
|
||||
key="add"
|
||||
isDisabled={isDisabled}
|
||||
component="Button"
|
||||
onClick={this.handleConfirmDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<Tooltip content={this.renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={this.handleConfirmDelete}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={modalTitle}
|
||||
isOpen={isModalOpen}
|
||||
onClose={this.handleCancelDelete}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={this.handleDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={this.handleCancelDelete}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||
{itemsToDelete.map(item => (
|
||||
<span key={item.id}>
|
||||
<strong>{item.name || item.username}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</AlertModal>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Kebabified>
|
||||
);
|
||||
}
|
||||
// NOTE: Once PF supports tooltips on disabled elements,
|
||||
// we can delete the extra <div> around the <DeleteButton> below.
|
||||
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
||||
return (
|
||||
<>
|
||||
{isKebabified ? (
|
||||
<DropdownItem
|
||||
key="add"
|
||||
isDisabled={isDisabled}
|
||||
component="button"
|
||||
onClick={toggleModal}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<Tooltip content={renderTooltip()} position="top">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={modalTitle}
|
||||
isOpen={isModalOpen}
|
||||
onClose={toggleModal}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={toggleModal}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||
{itemsToDelete.map(item => (
|
||||
<span key={item.id}>
|
||||
<strong>{item.name || item.username}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ToolbarDeleteButton.propTypes = {
|
||||
onDelete: func.isRequired,
|
||||
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
||||
pluralizedItemName: string,
|
||||
errorMessage: string,
|
||||
};
|
||||
|
||||
ToolbarDeleteButton.defaultProps = {
|
||||
pluralizedItemName: 'Items',
|
||||
errorMessage: '',
|
||||
};
|
||||
|
||||
export default withI18n()(ToolbarDeleteButton);
|
||||
|
||||
@@ -26,8 +26,8 @@ describe('<ToolbarDeleteButton />', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
||||
);
|
||||
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(true);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||
});
|
||||
@@ -37,15 +37,14 @@ describe('<ToolbarDeleteButton />', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
|
||||
);
|
||||
wrapper.find('ToolbarDeleteButton').setState({ isModalOpen: true });
|
||||
wrapper.find('button').simulate('click');
|
||||
wrapper.update();
|
||||
wrapper
|
||||
.find('ModalBoxFooter button[aria-label="confirm delete"]')
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(
|
||||
false
|
||||
);
|
||||
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||
});
|
||||
|
||||
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();
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
@@ -302,7 +304,9 @@ describe('<TemplateList />', () => {
|
||||
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();
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
|
||||
Reference in New Issue
Block a user