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:
softwarefactory-project-zuul[bot]
2020-09-11 15:07:30 +00:00
committed by GitHub
9 changed files with 589 additions and 171 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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