mirror of
https://github.com/ansible/awx.git
synced 2026-05-09 18:37:36 -02:30
Refactors and redesigns workflow approval to impove UX
This commit is contained in:
@@ -20,12 +20,7 @@ function NavExpandableGroup(props) {
|
|||||||
if (routes.length === 1 && groupId === 'settings') {
|
if (routes.length === 1 && groupId === 'settings') {
|
||||||
const [{ path }] = routes;
|
const [{ path }] = routes;
|
||||||
return (
|
return (
|
||||||
<NavItem
|
<NavItem itemId={groupId} isActive={isActivePath(path)} key={path}>
|
||||||
itemId={groupId}
|
|
||||||
isActive={isActivePath(path)}
|
|
||||||
key={path}
|
|
||||||
// ouiaId={path}
|
|
||||||
>
|
|
||||||
<Link to={path}>{groupTitle}</Link>
|
<Link to={path}>{groupTitle}</Link>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
);
|
);
|
||||||
@@ -40,12 +35,7 @@ function NavExpandableGroup(props) {
|
|||||||
title={groupTitle}
|
title={groupTitle}
|
||||||
>
|
>
|
||||||
{routes.map(({ path, title }) => (
|
{routes.map(({ path, title }) => (
|
||||||
<NavItem
|
<NavItem groupId={groupId} isActive={isActivePath(path)} key={path}>
|
||||||
groupId={groupId}
|
|
||||||
isActive={isActivePath(path)}
|
|
||||||
key={path}
|
|
||||||
// ouiaId={path}
|
|
||||||
>
|
|
||||||
<Link to={path}>{title}</Link>
|
<Link to={path}>{title}</Link>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ function JobCancelButton({
|
|||||||
buttonText,
|
buttonText,
|
||||||
style = {},
|
style = {},
|
||||||
job = {},
|
job = {},
|
||||||
|
isDisabled,
|
||||||
|
tooltip,
|
||||||
|
cancelationMessage,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { error: cancelError, request: cancelJob } = useRequest(
|
const { error: cancelError, request: cancelJob } = useRequest(
|
||||||
@@ -28,33 +31,40 @@ function JobCancelButton({
|
|||||||
useDismissableError(cancelError);
|
useDismissableError(cancelError);
|
||||||
|
|
||||||
const isAlreadyCancelled = cancelError?.response?.status === 405;
|
const isAlreadyCancelled = cancelError?.response?.status === 405;
|
||||||
|
const renderTooltip = () => {
|
||||||
|
if (tooltip) {
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
return isAlreadyCancelled ? null : title;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip content={isAlreadyCancelled ? null : title}>
|
<Tooltip content={renderTooltip()}>
|
||||||
{showIconButton ? (
|
<div>
|
||||||
<Button
|
{showIconButton ? (
|
||||||
isDisabled={isAlreadyCancelled}
|
<Button
|
||||||
aria-label={title}
|
isDisabled={isDisabled || isAlreadyCancelled}
|
||||||
ouiaId="cancel-job-button"
|
aria-label={title}
|
||||||
onClick={() => setIsOpen(true)}
|
ouiaId="cancel-job-button"
|
||||||
variant="plain"
|
onClick={() => setIsOpen(true)}
|
||||||
style={style}
|
variant="plain"
|
||||||
>
|
style={style}
|
||||||
<MinusCircleIcon />
|
>
|
||||||
</Button>
|
<MinusCircleIcon />
|
||||||
) : (
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
isDisabled={isAlreadyCancelled}
|
<Button
|
||||||
aria-label={title}
|
isDisabled={isDisabled || isAlreadyCancelled}
|
||||||
variant="secondary"
|
aria-label={title}
|
||||||
ouiaId="cancel-job-button"
|
variant="secondary"
|
||||||
onClick={() => setIsOpen(true)}
|
ouiaId="cancel-job-button"
|
||||||
style={style}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
style={style}
|
||||||
{buttonText || t`Cancel Job`}
|
>
|
||||||
</Button>
|
{buttonText || t`Cancel Job`}
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -86,7 +96,7 @@ function JobCancelButton({
|
|||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{t`Are you sure you want to cancel this job?`}
|
{cancelationMessage ?? t`Are you sure you want to cancel this job?`}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
{error && !isAlreadyCancelled && (
|
{error && !isAlreadyCancelled && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link, useHistory, useParams } from 'react-router-dom';
|
import { Link, useHistory, useParams } from 'react-router-dom';
|
||||||
@@ -26,13 +26,14 @@ import {
|
|||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import { WorkflowApproval } from 'types';
|
import { WorkflowApproval } from 'types';
|
||||||
import StatusLabel from 'components/StatusLabel';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
import JobCancelButton from 'components/JobCancelButton';
|
||||||
|
import WorkflowApprovalButton from '../shared/WorkflowApprovalButton';
|
||||||
|
import WorkflowDenyButton from '../shared/WorkflowDenyButton';
|
||||||
import {
|
import {
|
||||||
getDetailPendingLabel,
|
getDetailPendingLabel,
|
||||||
getStatus,
|
getStatus,
|
||||||
} from '../shared/WorkflowApprovalUtils';
|
} from '../shared/WorkflowApprovalUtils';
|
||||||
|
|
||||||
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
|
|
||||||
|
|
||||||
const Divider = styled(PFDivider)`
|
const Divider = styled(PFDivider)`
|
||||||
margin-top: var(--pf-global--spacer--lg);
|
margin-top: var(--pf-global--spacer--lg);
|
||||||
margin-bottom: var(--pf-global--spacer--lg);
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
@@ -49,7 +50,6 @@ const WFDetailList = styled(DetailList)`
|
|||||||
|
|
||||||
function WorkflowApprovalDetail({ workflowApproval }) {
|
function WorkflowApprovalDetail({ workflowApproval }) {
|
||||||
const { id: workflowApprovalId } = useParams();
|
const { id: workflowApprovalId } = useParams();
|
||||||
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const {
|
const {
|
||||||
request: deleteWorkflowApproval,
|
request: deleteWorkflowApproval,
|
||||||
@@ -65,50 +65,6 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
const { error: deleteError, dismissError: dismissDeleteError } =
|
const { error: deleteError, dismissError: dismissDeleteError } =
|
||||||
useDismissableError(deleteApprovalError);
|
useDismissableError(deleteApprovalError);
|
||||||
|
|
||||||
const {
|
|
||||||
error: approveApprovalError,
|
|
||||||
isLoading: isApproveLoading,
|
|
||||||
request: approveWorkflowApproval,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
await WorkflowApprovalsAPI.approve(workflowApprovalId);
|
|
||||||
history.push(`/workflow_approvals/${workflowApprovalId}`);
|
|
||||||
}, [workflowApprovalId, history]),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error: approveError, dismissError: dismissApproveError } =
|
|
||||||
useDismissableError(approveApprovalError);
|
|
||||||
|
|
||||||
const {
|
|
||||||
error: denyApprovalError,
|
|
||||||
isLoading: isDenyLoading,
|
|
||||||
request: denyWorkflowApproval,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
await WorkflowApprovalsAPI.deny(workflowApprovalId);
|
|
||||||
history.push(`/workflow_approvals/${workflowApprovalId}`);
|
|
||||||
}, [workflowApprovalId, history]),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error: denyError, dismissError: dismissDenyError } =
|
|
||||||
useDismissableError(denyApprovalError);
|
|
||||||
|
|
||||||
const {
|
|
||||||
error: cancelApprovalError,
|
|
||||||
isLoading: isCancelLoading,
|
|
||||||
request: cancelWorkflowApprovals,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
await WorkflowJobsAPI.cancel(
|
|
||||||
workflowApproval.summary_fields.source_workflow_job.id
|
|
||||||
);
|
|
||||||
history.push(`/workflow_approvals/${workflowApprovalId}`);
|
|
||||||
}, [workflowApproval, workflowApprovalId, history]),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const workflowJobTemplateId =
|
const workflowJobTemplateId =
|
||||||
workflowApproval.summary_fields.workflow_job_template.id;
|
workflowApproval.summary_fields.workflow_job_template.id;
|
||||||
|
|
||||||
@@ -146,26 +102,13 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
fetchWorkflowJob();
|
fetchWorkflowJob();
|
||||||
}, [fetchWorkflowJob]);
|
}, [fetchWorkflowJob]);
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
setIsKebabModalOpen(false);
|
|
||||||
await cancelWorkflowApprovals();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { error: cancelError, dismissError: dismissCancelError } =
|
|
||||||
useDismissableError(cancelApprovalError);
|
|
||||||
|
|
||||||
const sourceWorkflowJob =
|
const sourceWorkflowJob =
|
||||||
workflowApproval?.summary_fields?.source_workflow_job;
|
workflowApproval?.summary_fields?.source_workflow_job;
|
||||||
|
|
||||||
const sourceWorkflowJobTemplate =
|
const sourceWorkflowJobTemplate =
|
||||||
workflowApproval?.summary_fields?.workflow_job_template;
|
workflowApproval?.summary_fields?.workflow_job_template;
|
||||||
|
|
||||||
const isLoading =
|
const isLoading = isDeleteLoading || isLoadingWorkflowJob;
|
||||||
isApproveLoading ||
|
|
||||||
isCancelLoading ||
|
|
||||||
isDeleteLoading ||
|
|
||||||
isDenyLoading ||
|
|
||||||
isLoadingWorkflowJob;
|
|
||||||
|
|
||||||
if (isLoadingWorkflowJob) {
|
if (isLoadingWorkflowJob) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
@@ -342,16 +285,23 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{workflowApproval.status === 'pending' &&
|
{workflowApproval.status === 'pending' &&
|
||||||
workflowApproval.can_approve_or_deny && (
|
workflowApproval.can_approve_or_deny && (
|
||||||
<WorkflowApprovalControls
|
<>
|
||||||
selected={[workflowApproval]}
|
<WorkflowApprovalButton
|
||||||
onHandleApprove={approveWorkflowApproval}
|
workflowApproval={workflowApproval}
|
||||||
onHandleDeny={denyWorkflowApproval}
|
isDetailView
|
||||||
onHandleCancel={handleCancel}
|
/>
|
||||||
onHandleToggleToolbarKebab={(isOpen) =>
|
<WorkflowDenyButton
|
||||||
setIsKebabModalOpen(isOpen)
|
workflowApproval={workflowApproval}
|
||||||
}
|
isDetailView
|
||||||
isKebabOpen={isKebabOpen}
|
/>
|
||||||
/>
|
<JobCancelButton
|
||||||
|
title={t`Cancel Workflow`}
|
||||||
|
job={workflowApproval.summary_fields.source_workflow_job}
|
||||||
|
buttonText={t`Cancel Workflow`}
|
||||||
|
cancelationMessage={t`This will cancel all subsequent nodes in this workflow.
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{workflowApproval.status !== 'pending' &&
|
{workflowApproval.status !== 'pending' &&
|
||||||
workflowApproval.summary_fields?.user_capabilities?.delete && (
|
workflowApproval.summary_fields?.user_capabilities?.delete && (
|
||||||
@@ -376,39 +326,6 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
<ErrorDetail error={deleteError} />
|
<ErrorDetail error={deleteError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
{approveError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={approveError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissApproveError}
|
|
||||||
>
|
|
||||||
{t`Failed to approve workflow approval.`}
|
|
||||||
<ErrorDetail error={approveError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
{cancelError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={cancelError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissCancelError}
|
|
||||||
>
|
|
||||||
{t`Failed to approve workflow approval.`}
|
|
||||||
<ErrorDetail error={cancelError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
{denyError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={denyError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissDenyError}
|
|
||||||
>
|
|
||||||
{t`Failed to deny workflow approval.`}
|
|
||||||
<ErrorDetail error={denyError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,7 +326,6 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
|
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
|
||||||
'{"foo": "bar", "baz": "qux", "first_one": 10}'
|
'{"foo": "bar", "baz": "qux", "first_one": 10}'
|
||||||
);
|
);
|
||||||
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show expiration date/time', async () => {
|
test('should show expiration date/time', async () => {
|
||||||
@@ -521,7 +520,10 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
});
|
});
|
||||||
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('DropdownToggleAction').invoke('onClick')();
|
wrapper
|
||||||
|
.find('Button[ouiaId="workflow-approve-button"]')
|
||||||
|
.at(0)
|
||||||
|
.invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
|
||||||
await waitForElement(
|
await waitForElement(
|
||||||
@@ -550,16 +552,8 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
||||||
await act(async () => wrapper.find('Toggle').prop('onToggle')(true));
|
|
||||||
wrapper.update();
|
|
||||||
await waitForElement(
|
|
||||||
wrapper,
|
|
||||||
'WorkflowApprovalDetail DropdownItem[ouiaId="workflow-deny-button"]'
|
|
||||||
);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper.find('Button[ouiaId="workflow-deny-button"]').invoke('onClick')();
|
||||||
.find('DropdownItem[ouiaId="workflow-deny-button"]')
|
|
||||||
.invoke('onClick')();
|
|
||||||
});
|
});
|
||||||
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
|
||||||
await waitForElement(
|
await waitForElement(
|
||||||
@@ -577,93 +571,6 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete button should be hidden when user cannot delete', async () => {
|
|
||||||
let wrapper;
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalDetail
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
status: 'successful',
|
|
||||||
summary_fields: {
|
|
||||||
...workflowApproval.summary_fields,
|
|
||||||
user_capabilities: {
|
|
||||||
delete: false,
|
|
||||||
start: false,
|
|
||||||
},
|
|
||||||
approved_or_denied_by: {
|
|
||||||
id: 1,
|
|
||||||
username: 'Foobar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
|
||||||
expect(wrapper.find('DeleteButton').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete button should be hidden when job is pending and approve, action buttons should render', async () => {
|
|
||||||
let wrapper;
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalDetail
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
summary_fields: {
|
|
||||||
...workflowApproval.summary_fields,
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
start: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
can_approve_or_deny: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
|
||||||
|
|
||||||
expect(wrapper.find('DeleteButton').length).toBe(0);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
|
|
||||||
await act(async () => wrapper.find('Toggle').prop('onToggle')(true));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const denyItem = wrapper.find(
|
|
||||||
'DropdownItem[ouiaId="workflow-deny-button"]'
|
|
||||||
);
|
|
||||||
const cancelItem = wrapper.find(
|
|
||||||
'DropdownItem[ouiaId="workflow-cancel-button"]'
|
|
||||||
);
|
|
||||||
expect(denyItem).toHaveLength(1);
|
|
||||||
expect(denyItem.prop('description')).toBe(
|
|
||||||
'This will continue the workflow along failure and always paths.'
|
|
||||||
);
|
|
||||||
expect(cancelItem).toHaveLength(1);
|
|
||||||
expect(cancelItem.prop('description')).toBe(
|
|
||||||
'This will cancel the workflow and no subsequent nodes will execute.'
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
wrapper.find('DropdownItem[ouiaId="workflow-delete-button"]')
|
|
||||||
).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Delete button is visible and approve action is not', async () => {
|
|
||||||
let wrapper;
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalDetail
|
|
||||||
workflowApproval={mockWorkflowApprovals.results[1]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
|
|
||||||
expect(wrapper.find('DeleteButton').length).toBe(1);
|
|
||||||
expect(wrapper.find('DeleteButton').prop('isDisabled')).toBe(false);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Error dialog shown for failed deletion', async () => {
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
|
WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
|
||||||
Promise.reject(new Error())
|
Promise.reject(new Error())
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
|
export { default } from './WorkflowApprovalDetail';
|
||||||
|
|
||||||
export default WorkflowApprovalDetail;
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
|
import { WorkflowApprovalsAPI } from 'api';
|
||||||
import PaginatedTable, {
|
import PaginatedTable, {
|
||||||
HeaderRow,
|
HeaderRow,
|
||||||
HeaderCell,
|
HeaderCell,
|
||||||
@@ -12,15 +12,11 @@ import PaginatedTable, {
|
|||||||
import AlertModal from 'components/AlertModal';
|
import AlertModal from 'components/AlertModal';
|
||||||
import ErrorDetail from 'components/ErrorDetail';
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
import DataListToolbar from 'components/DataListToolbar';
|
import DataListToolbar from 'components/DataListToolbar';
|
||||||
import useRequest, {
|
import useRequest, { useDeleteItems } from 'hooks/useRequest';
|
||||||
useDeleteItems,
|
|
||||||
useDismissableError,
|
|
||||||
} from 'hooks/useRequest';
|
|
||||||
import useSelected from 'hooks/useSelected';
|
import useSelected from 'hooks/useSelected';
|
||||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||||
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
||||||
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
||||||
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
|
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -31,7 +27,6 @@ const QS_CONFIG = getQSConfig('workflow_approvals', {
|
|||||||
function WorkflowApprovalsList() {
|
function WorkflowApprovalsList() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -109,79 +104,7 @@ function WorkflowApprovalsList() {
|
|||||||
clearSelected();
|
clearSelected();
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const isLoading = isWorkflowApprovalsLoading || isDeleteLoading;
|
||||||
error: approveApprovalError,
|
|
||||||
isLoading: isApproveLoading,
|
|
||||||
request: approveWorkflowApprovals,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(
|
|
||||||
async () =>
|
|
||||||
Promise.all(selected.map(({ id }) => WorkflowApprovalsAPI.approve(id))),
|
|
||||||
[selected]
|
|
||||||
),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
await approveWorkflowApprovals();
|
|
||||||
clearSelected();
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
error: denyApprovalError,
|
|
||||||
isLoading: isDenyLoading,
|
|
||||||
request: denyWorkflowApprovals,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(
|
|
||||||
async () =>
|
|
||||||
Promise.all(selected.map(({ id }) => WorkflowApprovalsAPI.deny(id))),
|
|
||||||
[selected]
|
|
||||||
),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeny = async () => {
|
|
||||||
setIsKebabModalOpen(false);
|
|
||||||
await denyWorkflowApprovals();
|
|
||||||
clearSelected();
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
error: cancelApprovalError,
|
|
||||||
isLoading: isCancelLoading,
|
|
||||||
request: cancelWorkflowApprovals,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(
|
|
||||||
async () =>
|
|
||||||
Promise.all(
|
|
||||||
selected.map(({ summary_fields }) =>
|
|
||||||
WorkflowJobsAPI.cancel(summary_fields.source_workflow_job.id)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[selected]
|
|
||||||
),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
setIsKebabModalOpen(false);
|
|
||||||
await cancelWorkflowApprovals();
|
|
||||||
clearSelected();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { error: approveError, dismissError: dismissApproveError } =
|
|
||||||
useDismissableError(approveApprovalError);
|
|
||||||
const { error: cancelError, dismissError: dismissCancelError } =
|
|
||||||
useDismissableError(cancelApprovalError);
|
|
||||||
const { error: denyError, dismissError: dismissDenyError } =
|
|
||||||
useDismissableError(denyApprovalError);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
isWorkflowApprovalsLoading ||
|
|
||||||
isDeleteLoading ||
|
|
||||||
isApproveLoading ||
|
|
||||||
isDenyLoading ||
|
|
||||||
isCancelLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -215,17 +138,6 @@ function WorkflowApprovalsList() {
|
|||||||
onSelectAll={selectAll}
|
onSelectAll={selectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
<WorkflowApprovalControls
|
|
||||||
key="approvalControls"
|
|
||||||
onHandleApprove={handleApprove}
|
|
||||||
selected={selected}
|
|
||||||
onHandleDeny={handleDeny}
|
|
||||||
onHandleCancel={handleCancel}
|
|
||||||
onHandleToggleToolbarKebab={(isOpen) =>
|
|
||||||
setIsKebabModalOpen(isOpen)
|
|
||||||
}
|
|
||||||
isKebabOpen={isKebabOpen}
|
|
||||||
/>,
|
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
@@ -252,6 +164,7 @@ function WorkflowApprovalsList() {
|
|||||||
<HeaderCell>{t`Workflow Job`}</HeaderCell>
|
<HeaderCell>{t`Workflow Job`}</HeaderCell>
|
||||||
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
|
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
|
||||||
<HeaderCell>{t`Status`}</HeaderCell>
|
<HeaderCell>{t`Status`}</HeaderCell>
|
||||||
|
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
}
|
}
|
||||||
renderRow={(workflowApproval, index) => (
|
renderRow={(workflowApproval, index) => (
|
||||||
@@ -263,7 +176,6 @@ function WorkflowApprovalsList() {
|
|||||||
(row) => row.id === workflowApproval.id
|
(row) => row.id === workflowApproval.id
|
||||||
)}
|
)}
|
||||||
onSelect={() => handleSelect(workflowApproval)}
|
onSelect={() => handleSelect(workflowApproval)}
|
||||||
onSuccessfulAction={fetchWorkflowApprovals}
|
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -281,39 +193,6 @@ function WorkflowApprovalsList() {
|
|||||||
<ErrorDetail error={deletionError} />
|
<ErrorDetail error={deletionError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
{approveError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={approveError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissApproveError}
|
|
||||||
>
|
|
||||||
{t`Failed to approve one or more workflow approval.`}
|
|
||||||
<ErrorDetail error={approveError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
{cancelError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={cancelError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissCancelError}
|
|
||||||
>
|
|
||||||
{t`Failed to cancel one or more workflow approval.`}
|
|
||||||
<ErrorDetail error={cancelError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
{denyError && (
|
|
||||||
<AlertModal
|
|
||||||
isOpen={denyError}
|
|
||||||
variant="error"
|
|
||||||
title={t`Error!`}
|
|
||||||
onClose={dismissDenyError}
|
|
||||||
>
|
|
||||||
{t`Failed to deny one or more workflow approval.`}
|
|
||||||
<ErrorDetail error={denyError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,27 +79,7 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WorkflowapprovalControls is inactive. Delete button is active', async () => {
|
test('Delete button is active', async () => {
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
await act(async () => {
|
|
||||||
wrapper.find('WorkflowApprovalListItem').first().invoke('onSelect')();
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
wrapper
|
|
||||||
.find('WorkflowApprovalControls')
|
|
||||||
.find('DropdownToggle')
|
|
||||||
.prop('isDisabled')
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('WorkflowapprovalControls is inactive. Delete button is active', async () => {
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||||
});
|
});
|
||||||
@@ -111,33 +91,6 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(
|
|
||||||
wrapper
|
|
||||||
.find('WorkflowApprovalControls')
|
|
||||||
.find('DropdownToggle')
|
|
||||||
.prop('isDisabled')
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should disable WorkflowApprovalControls toggle with active delete button', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
wrapper.find('WorkflowApprovalListItem').at(3).invoke('onSelect')();
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
expect(
|
|
||||||
wrapper
|
|
||||||
.find('WorkflowApprovalControls')
|
|
||||||
.find('DropdownToggle')
|
|
||||||
.prop('isDisabled')
|
|
||||||
).toEqual(true);
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call delete api', async () => {
|
test('should call delete api', async () => {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { Link } from 'react-router-dom';
|
|||||||
import { WorkflowApproval } from 'types';
|
import { WorkflowApproval } from 'types';
|
||||||
import { formatDateString } from 'util/dates';
|
import { formatDateString } from 'util/dates';
|
||||||
import StatusLabel from 'components/StatusLabel';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
import JobCancelButton from 'components/JobCancelButton';
|
||||||
|
import { ActionItem, ActionsTd } from 'components/PaginatedTable';
|
||||||
import {
|
import {
|
||||||
getPendingLabel,
|
getPendingLabel,
|
||||||
getStatus,
|
getStatus,
|
||||||
getTooltip,
|
getTooltip,
|
||||||
} from '../shared/WorkflowApprovalUtils';
|
} from '../shared/WorkflowApprovalUtils';
|
||||||
|
import WorkflowApprovalButton from '../shared/WorkflowApprovalButton';
|
||||||
|
import WorkflowDenyButton from '../shared/WorkflowDenyButton';
|
||||||
|
|
||||||
function WorkflowApprovalListItem({
|
function WorkflowApprovalListItem({
|
||||||
workflowApproval,
|
workflowApproval,
|
||||||
@@ -19,8 +23,13 @@ function WorkflowApprovalListItem({
|
|||||||
detailUrl,
|
detailUrl,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
}) {
|
}) {
|
||||||
|
const hasBeenActedOn =
|
||||||
|
workflowApproval.status === 'successful' ||
|
||||||
|
workflowApproval.status === 'failed' ||
|
||||||
|
workflowApproval.status === 'canceled';
|
||||||
const labelId = `check-action-${workflowApproval.id}`;
|
const labelId = `check-action-${workflowApproval.id}`;
|
||||||
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
|
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
|
||||||
|
const status = getStatus(workflowApproval);
|
||||||
return (
|
return (
|
||||||
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
|
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
|
||||||
<Td
|
<Td
|
||||||
@@ -62,10 +71,48 @@ function WorkflowApprovalListItem({
|
|||||||
) : (
|
) : (
|
||||||
<StatusLabel
|
<StatusLabel
|
||||||
tooltipContent={getTooltip(workflowApproval)}
|
tooltipContent={getTooltip(workflowApproval)}
|
||||||
status={getStatus(workflowApproval)}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
|
<ActionsTd dataLabel={t`Actions`}>
|
||||||
|
<ActionItem
|
||||||
|
visible
|
||||||
|
tooltip={
|
||||||
|
hasBeenActedOn
|
||||||
|
? t`This workflow has already been ${status}`
|
||||||
|
: t`Approve`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WorkflowApprovalButton workflowApproval={workflowApproval} />
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible
|
||||||
|
tooltip={
|
||||||
|
hasBeenActedOn
|
||||||
|
? t`This workflow has already been ${status}`
|
||||||
|
: t`Deny`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WorkflowDenyButton workflowApproval={workflowApproval} />
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem visible>
|
||||||
|
<JobCancelButton
|
||||||
|
title={t`Cancel Workflow`}
|
||||||
|
showIconButton
|
||||||
|
job={workflowApproval.summary_fields.source_workflow_job}
|
||||||
|
buttonText={t`Cancel Workflow`}
|
||||||
|
isDisabled={hasBeenActedOn}
|
||||||
|
tooltip={
|
||||||
|
hasBeenActedOn
|
||||||
|
? t`This workflow has already been ${status}`
|
||||||
|
: t`Cancel`
|
||||||
|
}
|
||||||
|
cancelationMessage={t`This will cancel all subsequent nodes in this workflow
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
</Tr>
|
</Tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button } from '@patternfly/react-core';
|
||||||
|
import { OutlinedThumbsUpIcon } from '@patternfly/react-icons';
|
||||||
|
import { WorkflowApprovalsAPI } from 'api';
|
||||||
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
|
|
||||||
|
import AlertModal from 'components/AlertModal';
|
||||||
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
|
import { getStatus } from './WorkflowApprovalUtils';
|
||||||
|
|
||||||
|
function WorkflowApprovalButton({ isDetailView, workflowApproval }) {
|
||||||
|
const { id } = workflowApproval;
|
||||||
|
const hasBeenActedOn = workflowApproval.status === 'successful';
|
||||||
|
const { error: approveApprovalError, request: approveWorkflowApprovals } =
|
||||||
|
useRequest(
|
||||||
|
useCallback(async () => WorkflowApprovalsAPI.approve(id), [id]),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
await approveWorkflowApprovals();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: approveError, dismissError: dismissApproveError } =
|
||||||
|
useDismissableError(approveApprovalError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
isDisabled={hasBeenActedOn}
|
||||||
|
variant={isDetailView ? 'primary' : 'plain'}
|
||||||
|
ouiaId="workflow-approve-button"
|
||||||
|
onClick={() => handleApprove()}
|
||||||
|
aria-label={
|
||||||
|
hasBeenActedOn
|
||||||
|
? t`This workflow has already been ${getStatus(workflowApproval)}`
|
||||||
|
: t`Approve`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isDetailView ? t`Approve` : <OutlinedThumbsUpIcon />}
|
||||||
|
</Button>
|
||||||
|
{approveError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={approveError}
|
||||||
|
variant="error"
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={dismissApproveError}
|
||||||
|
>
|
||||||
|
{t`Failed to approve ${workflowApproval.name}.`}
|
||||||
|
<ErrorDetail error={approveError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default WorkflowApprovalButton;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
shallowWithContexts,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import { WorkflowApprovalsAPI } from 'api';
|
||||||
|
import WorkflowApprovalButton from './WorkflowApprovalButton';
|
||||||
|
import mockData from '../data.workflowApprovals.json';
|
||||||
|
|
||||||
|
jest.mock('api');
|
||||||
|
|
||||||
|
describe('<WorkflowApprovalButton/> shallow mount', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
let mockApprovalList = mockData.results;
|
||||||
|
|
||||||
|
test('initially render successfully', () => {
|
||||||
|
wrapper = shallowWithContexts(
|
||||||
|
<WorkflowApprovalButton workflowApproval={mockApprovalList[0]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('WorkflowApprovalButton')).toHaveLength(1);
|
||||||
|
wrapper
|
||||||
|
.find('Button')
|
||||||
|
.forEach((button) => expect(button.prop('isDisabled')).toBe(false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<WorkflowApprovalButton/>, full mount', () => {
|
||||||
|
let wrapper;
|
||||||
|
let mockApprovalList = mockData.results;
|
||||||
|
const approveButton = 'Button[ouiaId="workflow-approve-button"]';
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be disabled', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<WorkflowApprovalButton workflowApproval={mockApprovalList[2]} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find(approveButton)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(approveButton).prop('isDisabled')).toBe(true);
|
||||||
|
});
|
||||||
|
test('should handle approve', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<WorkflowApprovalButton workflowApproval={mockApprovalList[0]} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(() => wrapper.find(approveButton).prop('onClick')());
|
||||||
|
expect(WorkflowApprovalsAPI.approve).toBeCalledWith(218);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import {
|
|
||||||
Dropdown as PFDropdown,
|
|
||||||
DropdownItem,
|
|
||||||
Tooltip,
|
|
||||||
DropdownToggle,
|
|
||||||
DropdownToggleAction,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { CaretDownIcon } from '@patternfly/react-icons';
|
|
||||||
import { useKebabifiedMenu } from 'contexts/Kebabified';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
const Dropdown = styled(PFDropdown)`
|
|
||||||
--pf-c-dropdown__toggle--disabled--BackgroundColor: var(
|
|
||||||
--pf-global--disabled-color--200
|
|
||||||
);
|
|
||||||
|
|
||||||
&&& {
|
|
||||||
button {
|
|
||||||
color: var(--pf-c-dropdown__toggle--m-primary--Color);
|
|
||||||
}
|
|
||||||
div.pf-m-disabled > button {
|
|
||||||
color: var(--pf-global--disabled-color--100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function isPending(item) {
|
|
||||||
return item.status === 'pending';
|
|
||||||
}
|
|
||||||
function isComplete(item) {
|
|
||||||
return item.status !== 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowApprovalControls({
|
|
||||||
selected,
|
|
||||||
onHandleDeny,
|
|
||||||
onHandleCancel,
|
|
||||||
onHandleApprove,
|
|
||||||
onHandleToggleToolbarKebab,
|
|
||||||
isKebabOpen,
|
|
||||||
}) {
|
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
|
||||||
|
|
||||||
const hasSelectedItems = selected.length > 0;
|
|
||||||
const isApproveDenyOrCancelDisabled =
|
|
||||||
!hasSelectedItems ||
|
|
||||||
!selected.every(
|
|
||||||
(item) => item.status === 'pending' && item.can_approve_or_deny
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTooltip = (action) => {
|
|
||||||
if (!hasSelectedItems) {
|
|
||||||
return t`Select items to approve, deny, or cancel`;
|
|
||||||
}
|
|
||||||
if (!selected.some(isPending)) {
|
|
||||||
return t`Cannot approve, cancel or deny completed workflow approvals`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedItems = selected
|
|
||||||
.filter(isComplete)
|
|
||||||
.map((item) => item.name)
|
|
||||||
.join(', ');
|
|
||||||
if (selected.some(isPending) && selected.some(isComplete)) {
|
|
||||||
return t`The following selected items are complete and cannot be acted on: ${completedItems}`;
|
|
||||||
}
|
|
||||||
return action;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownItems = [
|
|
||||||
<Tooltip key="deny" content={renderTooltip(t`Deny`)}>
|
|
||||||
<div>
|
|
||||||
<DropdownItem
|
|
||||||
isDisabled={isApproveDenyOrCancelDisabled}
|
|
||||||
onClick={onHandleDeny}
|
|
||||||
ouiaId="workflow-deny-button"
|
|
||||||
description={t`This will continue the workflow along failure and always paths.`}
|
|
||||||
>
|
|
||||||
{t`Deny`}
|
|
||||||
</DropdownItem>
|
|
||||||
</div>
|
|
||||||
</Tooltip>,
|
|
||||||
<Tooltip key="cancel" content={renderTooltip(t`Cancel`)}>
|
|
||||||
<div>
|
|
||||||
<DropdownItem
|
|
||||||
isDisabled={isApproveDenyOrCancelDisabled}
|
|
||||||
ouiaId="workflow-cancel-button"
|
|
||||||
onClick={onHandleCancel}
|
|
||||||
description={t`This will cancel the workflow and no subsequent nodes will execute.`}
|
|
||||||
>
|
|
||||||
{t`Cancel`}
|
|
||||||
</DropdownItem>
|
|
||||||
</div>
|
|
||||||
</Tooltip>,
|
|
||||||
];
|
|
||||||
if (isKebabified) {
|
|
||||||
dropdownItems.unshift(
|
|
||||||
<Tooltip content={renderTooltip(t`Approve`)}>
|
|
||||||
<div>
|
|
||||||
<DropdownItem
|
|
||||||
isDisabled={isApproveDenyOrCancelDisabled}
|
|
||||||
onClick={onHandleApprove}
|
|
||||||
ouiaId="workflow-approve-button"
|
|
||||||
description={t`This will continue the workflow`}
|
|
||||||
>{t`Approve`}</DropdownItem>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
return dropdownItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
content={renderTooltip(t`Approve, cancel or deny`)}
|
|
||||||
key="workflowApproveOrDenyControls"
|
|
||||||
>
|
|
||||||
<Dropdown
|
|
||||||
toggle={
|
|
||||||
<DropdownToggle
|
|
||||||
isPrimary
|
|
||||||
splitButtonVariant="action"
|
|
||||||
toggleIndicator={CaretDownIcon}
|
|
||||||
splitButtonItems={[
|
|
||||||
<DropdownToggleAction
|
|
||||||
key="action"
|
|
||||||
type="button"
|
|
||||||
onClick={onHandleApprove}
|
|
||||||
>
|
|
||||||
{t`Approve`}
|
|
||||||
</DropdownToggleAction>,
|
|
||||||
]}
|
|
||||||
data-cy="actions-kebab-toogle"
|
|
||||||
isDisabled={isApproveDenyOrCancelDisabled}
|
|
||||||
onToggle={onHandleToggleToolbarKebab}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isOpen={isKebabOpen}
|
|
||||||
isPlain
|
|
||||||
ouiaId="actions-dropdown"
|
|
||||||
dropdownItems={dropdownItems}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default WorkflowApprovalControls;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
mountWithContexts,
|
|
||||||
shallowWithContexts,
|
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
|
||||||
import WorkflowApprovalControls from './WorkflowApprovalControls';
|
|
||||||
import mockData from '../data.workflowApprovals.json';
|
|
||||||
|
|
||||||
describe('<WorkflowApprovalControls/> shallow mount', () => {
|
|
||||||
let wrapper;
|
|
||||||
let onHandleDeny = jest.fn();
|
|
||||||
let onHandleCancel = jest.fn();
|
|
||||||
let onHandleToggleToolbarKebab = jest.fn();
|
|
||||||
let mockApprovalList = mockData.results;
|
|
||||||
|
|
||||||
test('initially render successfully', () => {
|
|
||||||
wrapper = shallowWithContexts(
|
|
||||||
<WorkflowApprovalControls
|
|
||||||
selected={[]}
|
|
||||||
onHandleDeny={onHandleDeny}
|
|
||||||
onHandleCancel={onHandleCancel}
|
|
||||||
onHandleToggleToolbarKebab={onHandleToggleToolbarKebab}
|
|
||||||
isKebabOpen={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls')).toHaveLength(1);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls').prop('isKebabOpen')).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test('is open with the correct dropdown options', async () => {
|
|
||||||
wrapper = shallowWithContexts(
|
|
||||||
<WorkflowApprovalControls
|
|
||||||
selected={mockApprovalList}
|
|
||||||
onHandleDeny={onHandleDeny}
|
|
||||||
onHandleCancel={onHandleCancel}
|
|
||||||
onHandleToggleToolbarKebab={onHandleToggleToolbarKebab}
|
|
||||||
isKebabOpen={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls')).toHaveLength(1);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls').prop('isKebabOpen')).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<WorkflowApprovalControls/>, full mount', () => {
|
|
||||||
let wrapper;
|
|
||||||
let onHandleDeny = jest.fn();
|
|
||||||
let onHandleCancel = jest.fn();
|
|
||||||
let onHandleToggleToolbarKebab = jest.fn();
|
|
||||||
let mockApprovalList = mockData.results;
|
|
||||||
const cancelButton = 'DropdownItem[ouiaId="workflow-cancel-button"]';
|
|
||||||
const denyButton = 'DropdownItem[ouiaId="workflow-deny-button"]';
|
|
||||||
|
|
||||||
test('initially render successfully', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalControls
|
|
||||||
selected={[]}
|
|
||||||
onHandleDeny={onHandleDeny}
|
|
||||||
onHandleCancel={onHandleCancel}
|
|
||||||
onHandleToggleToolbarKebab={onHandleToggleToolbarKebab}
|
|
||||||
isKebabOpen={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls')).toHaveLength(1);
|
|
||||||
expect(wrapper.find('WorkflowApprovalControls').prop('isKebabOpen')).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(wrapper.find('Tooltip').prop('content')).toBe(
|
|
||||||
'Select items to approve, deny, or cancel'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should call correct functions', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalControls
|
|
||||||
selected={[mockApprovalList[1], mockApprovalList[2]]}
|
|
||||||
onHandleDeny={onHandleDeny}
|
|
||||||
onHandleCancel={onHandleCancel}
|
|
||||||
onHandleToggleToolbarKebab={onHandleToggleToolbarKebab}
|
|
||||||
isKebabOpen={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
wrapper.find(denyButton).prop('onClick')();
|
|
||||||
expect(onHandleDeny).toHaveBeenCalled();
|
|
||||||
wrapper.find(cancelButton).prop('onClick')();
|
|
||||||
expect(onHandleCancel).toHaveBeenCalled();
|
|
||||||
wrapper.find('DropdownToggle').prop('onToggle')(false);
|
|
||||||
expect(onHandleToggleToolbarKebab).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button } from '@patternfly/react-core';
|
||||||
|
import { OutlinedThumbsDownIcon } from '@patternfly/react-icons';
|
||||||
|
import { WorkflowApprovalsAPI } from 'api';
|
||||||
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
|
|
||||||
|
import AlertModal from 'components/AlertModal';
|
||||||
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
|
import { getStatus } from './WorkflowApprovalUtils';
|
||||||
|
|
||||||
|
function WorkflowDenyButton({ isDetailView, workflowApproval }) {
|
||||||
|
const hasBeenActedOn = workflowApproval.status === 'failed';
|
||||||
|
const { id } = workflowApproval;
|
||||||
|
|
||||||
|
const { error: denyApprovalError, request: denyWorkflowApprovals } =
|
||||||
|
useRequest(
|
||||||
|
useCallback(async () => WorkflowApprovalsAPI.deny(id), [id]),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeny = async () => {
|
||||||
|
await denyWorkflowApprovals();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: denyError, dismissError: dismissDenyError } =
|
||||||
|
useDismissableError(denyApprovalError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
aria-label={
|
||||||
|
hasBeenActedOn
|
||||||
|
? t`This workflow has already been ${getStatus(workflowApproval)}`
|
||||||
|
: t`Deny`
|
||||||
|
}
|
||||||
|
ouiaId="workflow-deny-button"
|
||||||
|
isDisabled={hasBeenActedOn}
|
||||||
|
variant={isDetailView ? 'secondary' : 'plain'}
|
||||||
|
onClick={() => handleDeny()}
|
||||||
|
>
|
||||||
|
{isDetailView ? t`Deny` : <OutlinedThumbsDownIcon />}
|
||||||
|
</Button>
|
||||||
|
{denyError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={denyError}
|
||||||
|
variant="error"
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={dismissDenyError}
|
||||||
|
>
|
||||||
|
{t`Failed to deny ${workflowApproval.name}.`}
|
||||||
|
<ErrorDetail error={denyError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default WorkflowDenyButton;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
shallowWithContexts,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import { WorkflowApprovalsAPI } from 'api';
|
||||||
|
import WorkflowDenyButton from './WorkflowDenyButton';
|
||||||
|
import mockData from '../data.workflowApprovals.json';
|
||||||
|
|
||||||
|
jest.mock('api');
|
||||||
|
|
||||||
|
describe('<WorkflowDenyButton/> shallow mount', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
let mockApprovalList = mockData.results;
|
||||||
|
|
||||||
|
test('initially render successfully', () => {
|
||||||
|
wrapper = shallowWithContexts(
|
||||||
|
<WorkflowDenyButton workflowApproval={mockApprovalList[0]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('WorkflowDenyButton')).toHaveLength(1);
|
||||||
|
wrapper
|
||||||
|
.find('Button')
|
||||||
|
.forEach((button) => expect(button.prop('isDisabled')).toBe(false));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<WorkflowDenyButton/>, full mount', () => {
|
||||||
|
let wrapper;
|
||||||
|
let mockApprovalList = mockData.results;
|
||||||
|
const denyButton = 'Button[ouiaId="workflow-deny-button"]';
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be disabled', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<WorkflowDenyButton workflowApproval={mockApprovalList[1]} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find(denyButton)).toHaveLength(1);
|
||||||
|
expect(wrapper.find(denyButton).prop('isDisabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deny', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<WorkflowDenyButton workflowApproval={mockApprovalList[0]} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(() => wrapper.find(denyButton).prop('onClick')());
|
||||||
|
expect(WorkflowApprovalsAPI.deny).toBeCalledWith(218);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should handle deny error', async () => {
|
||||||
|
WorkflowApprovalsAPI.deny.mockRejectedValue(
|
||||||
|
new Error({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/v2/workflow',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<WorkflowDenyButton workflowApproval={mockApprovalList[0]} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(() => wrapper.find(denyButton).prop('onClick')());
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user