Refactors and redesigns workflow approval to impove UX

This commit is contained in:
Alex Corey 2022-07-20 09:04:06 -04:00 committed by mabashian
parent 65771b7629
commit 1fca505b61
14 changed files with 371 additions and 659 deletions

View File

@ -20,12 +20,7 @@ function NavExpandableGroup(props) {
if (routes.length === 1 && groupId === 'settings') {
const [{ path }] = routes;
return (
<NavItem
itemId={groupId}
isActive={isActivePath(path)}
key={path}
// ouiaId={path}
>
<NavItem itemId={groupId} isActive={isActivePath(path)} key={path}>
<Link to={path}>{groupTitle}</Link>
</NavItem>
);
@ -40,12 +35,7 @@ function NavExpandableGroup(props) {
title={groupTitle}
>
{routes.map(({ path, title }) => (
<NavItem
groupId={groupId}
isActive={isActivePath(path)}
key={path}
// ouiaId={path}
>
<NavItem groupId={groupId} isActive={isActivePath(path)} key={path}>
<Link to={path}>{title}</Link>
</NavItem>
))}

View File

@ -15,6 +15,9 @@ function JobCancelButton({
buttonText,
style = {},
job = {},
isDisabled,
tooltip,
cancelationMessage,
}) {
const [isOpen, setIsOpen] = useState(false);
const { error: cancelError, request: cancelJob } = useRequest(
@ -28,33 +31,40 @@ function JobCancelButton({
useDismissableError(cancelError);
const isAlreadyCancelled = cancelError?.response?.status === 405;
const renderTooltip = () => {
if (tooltip) {
return tooltip;
}
return isAlreadyCancelled ? null : title;
};
return (
<>
<Tooltip content={isAlreadyCancelled ? null : title}>
{showIconButton ? (
<Button
isDisabled={isAlreadyCancelled}
aria-label={title}
ouiaId="cancel-job-button"
onClick={() => setIsOpen(true)}
variant="plain"
style={style}
>
<MinusCircleIcon />
</Button>
) : (
<Button
isDisabled={isAlreadyCancelled}
aria-label={title}
variant="secondary"
ouiaId="cancel-job-button"
onClick={() => setIsOpen(true)}
style={style}
>
{buttonText || t`Cancel Job`}
</Button>
)}
<Tooltip content={renderTooltip()}>
<div>
{showIconButton ? (
<Button
isDisabled={isDisabled || isAlreadyCancelled}
aria-label={title}
ouiaId="cancel-job-button"
onClick={() => setIsOpen(true)}
variant="plain"
style={style}
>
<MinusCircleIcon />
</Button>
) : (
<Button
isDisabled={isDisabled || isAlreadyCancelled}
aria-label={title}
variant="secondary"
ouiaId="cancel-job-button"
onClick={() => setIsOpen(true)}
style={style}
>
{buttonText || t`Cancel Job`}
</Button>
)}
</div>
</Tooltip>
{isOpen && (
<AlertModal
@ -86,7 +96,7 @@ function JobCancelButton({
</Button>,
]}
>
{t`Are you sure you want to cancel this job?`}
{cancelationMessage ?? t`Are you sure you want to cancel this job?`}
</AlertModal>
)}
{error && !isAlreadyCancelled && (

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect } from 'react';
import { t } from '@lingui/macro';
import { Link, useHistory, useParams } from 'react-router-dom';
@ -26,13 +26,14 @@ import {
import useRequest, { useDismissableError } from 'hooks/useRequest';
import { WorkflowApproval } from 'types';
import StatusLabel from 'components/StatusLabel';
import JobCancelButton from 'components/JobCancelButton';
import WorkflowApprovalButton from '../shared/WorkflowApprovalButton';
import WorkflowDenyButton from '../shared/WorkflowDenyButton';
import {
getDetailPendingLabel,
getStatus,
} from '../shared/WorkflowApprovalUtils';
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
const Divider = styled(PFDivider)`
margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg);
@ -49,7 +50,6 @@ const WFDetailList = styled(DetailList)`
function WorkflowApprovalDetail({ workflowApproval }) {
const { id: workflowApprovalId } = useParams();
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
const history = useHistory();
const {
request: deleteWorkflowApproval,
@ -65,50 +65,6 @@ function WorkflowApprovalDetail({ workflowApproval }) {
const { error: deleteError, dismissError: dismissDeleteError } =
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 =
workflowApproval.summary_fields.workflow_job_template.id;
@ -146,26 +102,13 @@ function WorkflowApprovalDetail({ workflowApproval }) {
fetchWorkflowJob();
}, [fetchWorkflowJob]);
const handleCancel = async () => {
setIsKebabModalOpen(false);
await cancelWorkflowApprovals();
};
const { error: cancelError, dismissError: dismissCancelError } =
useDismissableError(cancelApprovalError);
const sourceWorkflowJob =
workflowApproval?.summary_fields?.source_workflow_job;
const sourceWorkflowJobTemplate =
workflowApproval?.summary_fields?.workflow_job_template;
const isLoading =
isApproveLoading ||
isCancelLoading ||
isDeleteLoading ||
isDenyLoading ||
isLoadingWorkflowJob;
const isLoading = isDeleteLoading || isLoadingWorkflowJob;
if (isLoadingWorkflowJob) {
return <ContentLoading />;
@ -342,16 +285,23 @@ function WorkflowApprovalDetail({ workflowApproval }) {
<CardActionsRow>
{workflowApproval.status === 'pending' &&
workflowApproval.can_approve_or_deny && (
<WorkflowApprovalControls
selected={[workflowApproval]}
onHandleApprove={approveWorkflowApproval}
onHandleDeny={denyWorkflowApproval}
onHandleCancel={handleCancel}
onHandleToggleToolbarKebab={(isOpen) =>
setIsKebabModalOpen(isOpen)
}
isKebabOpen={isKebabOpen}
/>
<>
<WorkflowApprovalButton
workflowApproval={workflowApproval}
isDetailView
/>
<WorkflowDenyButton
workflowApproval={workflowApproval}
isDetailView
/>
<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.summary_fields?.user_capabilities?.delete && (
@ -376,39 +326,6 @@ function WorkflowApprovalDetail({ workflowApproval }) {
<ErrorDetail error={deleteError} />
</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>
);
}

View File

@ -326,7 +326,6 @@ describe('<WorkflowApprovalDetail />', () => {
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'{"foo": "bar", "baz": "qux", "first_one": 10}'
);
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
});
test('should show expiration date/time', async () => {
@ -521,7 +520,10 @@ describe('<WorkflowApprovalDetail />', () => {
});
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
await act(async () => {
wrapper.find('DropdownToggleAction').invoke('onClick')();
wrapper
.find('Button[ouiaId="workflow-approve-button"]')
.at(0)
.invoke('onClick')();
});
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
await waitForElement(
@ -550,16 +552,8 @@ describe('<WorkflowApprovalDetail />', () => {
);
});
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 () => {
wrapper
.find('DropdownItem[ouiaId="workflow-deny-button"]')
.invoke('onClick')();
wrapper.find('Button[ouiaId="workflow-deny-button"]').invoke('onClick')();
});
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
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 () => {
WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())

View File

@ -1,3 +1 @@
import WorkflowApprovalDetail from './WorkflowApprovalDetail';
export default WorkflowApprovalDetail;
export { default } from './WorkflowApprovalDetail';

View File

@ -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 { t, Plural } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
import { WorkflowApprovalsAPI } from 'api';
import PaginatedTable, {
HeaderRow,
HeaderCell,
@ -12,15 +12,11 @@ import PaginatedTable, {
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import DataListToolbar from 'components/DataListToolbar';
import useRequest, {
useDeleteItems,
useDismissableError,
} from 'hooks/useRequest';
import useRequest, { useDeleteItems } from 'hooks/useRequest';
import useSelected from 'hooks/useSelected';
import { getQSConfig, parseQueryString } from 'util/qs';
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
const QS_CONFIG = getQSConfig('workflow_approvals', {
page: 1,
@ -31,7 +27,6 @@ const QS_CONFIG = getQSConfig('workflow_approvals', {
function WorkflowApprovalsList() {
const location = useLocation();
const match = useRouteMatch();
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
const {
result: { results, count, relatedSearchableKeys, searchableKeys },
@ -109,79 +104,7 @@ function WorkflowApprovalsList() {
clearSelected();
};
const {
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;
const isLoading = isWorkflowApprovalsLoading || isDeleteLoading;
return (
<>
@ -215,17 +138,6 @@ function WorkflowApprovalsList() {
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<WorkflowApprovalControls
key="approvalControls"
onHandleApprove={handleApprove}
selected={selected}
onHandleDeny={handleDeny}
onHandleCancel={handleCancel}
onHandleToggleToolbarKebab={(isOpen) =>
setIsKebabModalOpen(isOpen)
}
isKebabOpen={isKebabOpen}
/>,
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
@ -252,6 +164,7 @@ function WorkflowApprovalsList() {
<HeaderCell>{t`Workflow Job`}</HeaderCell>
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
<HeaderCell>{t`Status`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>
</HeaderRow>
}
renderRow={(workflowApproval, index) => (
@ -263,7 +176,6 @@ function WorkflowApprovalsList() {
(row) => row.id === workflowApproval.id
)}
onSelect={() => handleSelect(workflowApproval)}
onSuccessfulAction={fetchWorkflowApprovals}
rowIndex={index}
/>
)}
@ -281,39 +193,6 @@ function WorkflowApprovalsList() {
<ErrorDetail error={deletionError} />
</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>
)}
</>
);
}

View File

@ -79,27 +79,7 @@ describe('<WorkflowApprovalList />', () => {
).toEqual(true);
});
test('WorkflowapprovalControls is inactive. 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 () => {
test('Delete button is active', async () => {
await act(async () => {
wrapper = mountWithContexts(<WorkflowApprovalList />);
});
@ -111,33 +91,6 @@ describe('<WorkflowApprovalList />', () => {
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
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 () => {

View File

@ -6,11 +6,15 @@ import { Link } from 'react-router-dom';
import { WorkflowApproval } from 'types';
import { formatDateString } from 'util/dates';
import StatusLabel from 'components/StatusLabel';
import JobCancelButton from 'components/JobCancelButton';
import { ActionItem, ActionsTd } from 'components/PaginatedTable';
import {
getPendingLabel,
getStatus,
getTooltip,
} from '../shared/WorkflowApprovalUtils';
import WorkflowApprovalButton from '../shared/WorkflowApprovalButton';
import WorkflowDenyButton from '../shared/WorkflowDenyButton';
function WorkflowApprovalListItem({
workflowApproval,
@ -19,8 +23,13 @@ function WorkflowApprovalListItem({
detailUrl,
rowIndex,
}) {
const hasBeenActedOn =
workflowApproval.status === 'successful' ||
workflowApproval.status === 'failed' ||
workflowApproval.status === 'canceled';
const labelId = `check-action-${workflowApproval.id}`;
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
const status = getStatus(workflowApproval);
return (
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
<Td
@ -62,10 +71,48 @@ function WorkflowApprovalListItem({
) : (
<StatusLabel
tooltipContent={getTooltip(workflowApproval)}
status={getStatus(workflowApproval)}
status={status}
/>
)}
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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