Move approval action buttons from rows to to level list actions. UX updates to the display of the status.

This commit is contained in:
mabashian 2020-10-15 15:08:36 -04:00
parent ee7f73623f
commit a9c3484387
13 changed files with 607 additions and 295 deletions

View File

@ -44,6 +44,11 @@ function getRouteConfig(i18n) {
path: '/schedules',
screen: Schedules,
},
{
title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals',
screen: WorkflowApprovals,
},
],
},
{
@ -127,11 +132,6 @@ function getRouteConfig(i18n) {
path: '/applications',
screen: Applications,
},
{
title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals',
screen: WorkflowApprovals,
},
],
},
{

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory, useParams } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import DeleteButton from '../../../components/DeleteButton';
@ -11,19 +12,19 @@ import {
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
import { formatDateString, secondsToHHMMSS } from '../../../util/dates';
import { WorkflowApprovalsAPI } from '../../../api';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { WorkflowApproval } from '../../../types';
function WorkflowApprovalDetail({ i18n, workflowApproval }) {
const { id: workflowApprovalId } = useParams();
const history = useHistory();
const {
request: deleteWorkflowApproval,
isLoading,
error: deleteError,
isLoading: isDeleteLoading,
error: deleteApprovalError,
} = useRequest(
useCallback(async () => {
await WorkflowApprovalsAPI.destroy(workflowApprovalId);
@ -31,7 +32,44 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) {
}, [workflowApprovalId, history])
);
const { error, dismissError } = useDismissableError(deleteError);
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 sourceWorkflowJob =
workflowApproval?.summary_fields?.source_workflow_job;
@ -39,9 +77,7 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) {
const sourceWorkflowJobTemplate =
workflowApproval?.summary_fields?.workflow_job_template;
const handleSuccesfulAction = useCallback(() => {
history.push(`/workflow_approvals/${workflowApprovalId}`);
}, [history, workflowApprovalId]);
const isLoading = isDeleteLoading || isApproveLoading || isDenyLoading;
return (
<CardBody>
@ -135,11 +171,24 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) {
</DetailList>
<CardActionsRow>
{workflowApproval.can_approve_or_deny && (
<WorkflowApprovalActionButtons
icon={false}
workflowApproval={workflowApproval}
onSuccessfulAction={handleSuccesfulAction}
/>
<>
<Button
aria-label={i18n._(t`Approve`)}
variant="primary"
onClick={approveWorkflowApproval}
isDisabled={isLoading}
>
{i18n._(t`Approve`)}
</Button>
<Button
aria-label={i18n._(t`Deny`)}
variant="danger"
onClick={denyWorkflowApproval}
isDisabled={isLoading}
>
{i18n._(t`Deny`)}
</Button>
</>
)}
{workflowApproval.summary_fields.user_capabilities &&
workflowApproval.summary_fields.user_capabilities.delete && (
@ -153,19 +202,45 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) {
</DeleteButton>
)}
</CardActionsRow>
{error && (
{deleteError && (
<AlertModal
isOpen={error}
isOpen={deleteError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
onClose={dismissDeleteError}
>
{i18n._(t`Failed to delete workflow approval.`)}
<ErrorDetail error={error} />
<ErrorDetail error={deleteError} />
</AlertModal>
)}
{approveError && (
<AlertModal
isOpen={approveError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissApproveError}
>
{i18n._(t`Failed to approve workflow approval.`)}
<ErrorDetail error={approveError} />
</AlertModal>
)}
{denyError && (
<AlertModal
isOpen={denyError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissDenyError}
>
{i18n._(t`Failed to deny workflow approval.`)}
<ErrorDetail error={denyError} />
</AlertModal>
)}
</CardBody>
);
}
WorkflowApprovalDetail.defaultProps = {
workflowApproval: WorkflowApproval.isRequired,
};
export default withI18n()(WorkflowApprovalDetail);

View File

@ -48,7 +48,8 @@ describe('<WorkflowApprovalDetail />', () => {
);
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
assertDetail('Elapsed', '00:00:22');
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(1);
expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(1);
expect(wrapper.find('Button[aria-label="Deny"]').length).toBe(1);
expect(wrapper.find('DeleteButton').length).toBe(1);
});
@ -159,6 +160,66 @@ describe('<WorkflowApprovalDetail />', () => {
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0);
});
test('Error dialog shown for failed approval', async () => {
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
Promise.reject(new Error())
);
const wrapper = mountWithContexts(
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
);
await waitForElement(
wrapper,
'WorkflowApprovalDetail Button[aria-label="Approve"]'
);
await act(async () => {
wrapper.find('Button[aria-label="Approve"]').invoke('onClick')();
});
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
await waitForElement(
wrapper,
'Modal[title="Error!"]',
el => el.length === 1
);
await act(async () => {
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
});
await waitForElement(
wrapper,
'Modal[title="Error!"]',
el => el.length === 0
);
});
test('Error dialog shown for failed denial', async () => {
WorkflowApprovalsAPI.deny.mockImplementationOnce(() =>
Promise.reject(new Error())
);
const wrapper = mountWithContexts(
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
);
await waitForElement(
wrapper,
'WorkflowApprovalDetail Button[aria-label="Deny"]'
);
await act(async () => {
wrapper.find('Button[aria-label="Deny"]').invoke('onClick')();
});
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
await waitForElement(
wrapper,
'Modal[title="Error!"]',
el => el.length === 1
);
await act(async () => {
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
});
await waitForElement(
wrapper,
'Modal[title="Error!"]',
el => el.length === 0
);
});
test('delete button should be hidden when user cannot delete', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalDetail

View File

@ -11,10 +11,15 @@ import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar';
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import useRequest, {
useDeleteItems,
useDismissableError,
} from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
const QS_CONFIG = getQSConfig('workflow_approvals', {
page: 1,
@ -106,13 +111,64 @@ function WorkflowApprovalsList({ i18n }) {
setSelected([]);
};
const {
error: approveApprovalError,
isLoading: isApproveLoading,
request: approveWorkflowApprovals,
} = useRequest(
useCallback(async () => {
return Promise.all(
selected.map(({ id }) => WorkflowApprovalsAPI.approve(id))
);
}, [selected]),
{}
);
const handleApprove = async () => {
await approveWorkflowApprovals();
setSelected([]);
};
const {
error: approveError,
dismissError: dismissApproveError,
} = useDismissableError(approveApprovalError);
const {
error: denyApprovalError,
isLoading: isDenyLoading,
request: denyWorkflowApprovals,
} = useRequest(
useCallback(async () => {
return Promise.all(
selected.map(({ id }) => WorkflowApprovalsAPI.deny(id))
);
}, [selected]),
{}
);
const handleDeny = async () => {
await denyWorkflowApprovals();
setSelected([]);
};
const {
error: denyError,
dismissError: dismissDenyError,
} = useDismissableError(denyApprovalError);
return (
<>
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isWorkflowApprovalsLoading || isDeleteLoading}
hasContentLoading={
isWorkflowApprovalsLoading ||
isDeleteLoading ||
isApproveLoading ||
isDenyLoading
}
items={workflowApprovals}
itemCount={count}
pluralizedItemName={i18n._(t`Workflow Approvals`)}
@ -147,6 +203,16 @@ function WorkflowApprovalsList({ i18n }) {
}
qsConfig={QS_CONFIG}
additionalControls={[
<WorkflowApprovalListApproveButton
key="approve"
onApprove={handleApprove}
selectedItems={selected}
/>,
<WorkflowApprovalListDenyButton
key="deny"
onDeny={handleDeny}
selectedItems={selected}
/>,
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
@ -171,15 +237,39 @@ function WorkflowApprovalsList({ i18n }) {
/>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more workflow approval.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more workflow approval.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
{approveError && (
<AlertModal
isOpen={approveError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissApproveError}
>
{i18n._(t`Failed to approve one or more workflow approval.`)}
<ErrorDetail error={approveError} />
</AlertModal>
)}
{denyError && (
<AlertModal
isOpen={denyError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissDenyError}
>
{i18n._(t`Failed to deny one or more workflow approval.`)}
<ErrorDetail error={denyError} />
</AlertModal>
)}
</>
);
}

View File

@ -0,0 +1,76 @@
import React, { useContext } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { KebabifiedContext } from '../../../contexts/Kebabified';
import { WorkflowApproval } from '../../../types';
function cannotApprove(item) {
return !item.can_approve_or_deny;
}
function WorkflowApprovalListApproveButton({ onApprove, selectedItems, i18n }) {
const { isKebabified } = useContext(KebabifiedContext);
const renderTooltip = () => {
if (selectedItems.length === 0) {
return i18n._(t`Select a row to approve`);
}
const itemsUnableToApprove = selectedItems
.filter(cannotApprove)
.map(item => item.name)
.join(', ');
if (selectedItems.some(cannotApprove)) {
return i18n._(
t`You are unable to act on the following workflow approvals: ${itemsUnableToApprove}`
);
}
return i18n._(t`Approve`);
};
const isDisabled =
selectedItems.length === 0 || selectedItems.some(cannotApprove);
return (
<>
{isKebabified ? (
<DropdownItem
key="approve"
isDisabled={isDisabled}
component="button"
onClick={onApprove}
>
{i18n._(t`Approve`)}
</DropdownItem>
) : (
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Approve`)}
variant="primary"
onClick={onApprove}
>
{i18n._(t`Approve`)}
</Button>
</div>
</Tooltip>
)}
</>
);
}
WorkflowApprovalListApproveButton.propTypes = {
onApprove: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(WorkflowApproval),
};
WorkflowApprovalListApproveButton.defaultProps = {
selectedItems: [],
};
export default withI18n()(WorkflowApprovalListApproveButton);

View File

@ -0,0 +1,55 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
const workflowApproval = {
id: 1,
name: 'Foo',
can_approve_or_deny: true,
};
describe('<WorkflowApprovalListApproveButton />', () => {
test('should render button', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListApproveButton
onApprove={() => {}}
selectedItems={[]}
/>
);
expect(wrapper.find('button')).toHaveLength(1);
});
test('should invoke onApprove prop', () => {
const onApprove = jest.fn();
const wrapper = mountWithContexts(
<WorkflowApprovalListApproveButton
onApprove={onApprove}
selectedItems={[workflowApproval]}
/>
);
wrapper.find('button').simulate('click');
wrapper.update();
expect(onApprove).toHaveBeenCalled();
});
test('should disable button when no approve/deny permissions', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListApproveButton
onApprove={() => {}}
selectedItems={[{ ...workflowApproval, can_approve_or_deny: false }]}
/>
);
expect(wrapper.find('button[disabled]')).toHaveLength(1);
});
test('should render tooltip', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListApproveButton
onApprove={() => {}}
selectedItems={[workflowApproval]}
/>
);
expect(wrapper.find('Tooltip')).toHaveLength(1);
expect(wrapper.find('Tooltip').prop('content')).toEqual('Approve');
});
});

View File

@ -0,0 +1,76 @@
import React, { useContext } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { KebabifiedContext } from '../../../contexts/Kebabified';
import { WorkflowApproval } from '../../../types';
function cannotDeny(item) {
return !item.can_approve_or_deny;
}
function WorkflowApprovalListDenyButton({ onDeny, selectedItems, i18n }) {
const { isKebabified } = useContext(KebabifiedContext);
const renderTooltip = () => {
if (selectedItems.length === 0) {
return i18n._(t`Select a row to deny`);
}
const itemsUnableToDeny = selectedItems
.filter(cannotDeny)
.map(item => item.name)
.join(', ');
if (selectedItems.some(cannotDeny)) {
return i18n._(
t`You are unable to act on the following workflow approvals: ${itemsUnableToDeny}`
);
}
return i18n._(t`Deny`);
};
const isDisabled =
selectedItems.length === 0 || selectedItems.some(cannotDeny);
return (
<>
{isKebabified ? (
<DropdownItem
key="deny"
isDisabled={isDisabled}
component="button"
onClick={onDeny}
>
{i18n._(t`Deny`)}
</DropdownItem>
) : (
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Deny`)}
variant="danger"
onClick={onDeny}
>
{i18n._(t`Deny`)}
</Button>
</div>
</Tooltip>
)}
</>
);
}
WorkflowApprovalListDenyButton.propTypes = {
onDeny: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(WorkflowApproval),
};
WorkflowApprovalListDenyButton.defaultProps = {
selectedItems: [],
};
export default withI18n()(WorkflowApprovalListDenyButton);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
const workflowApproval = {
id: 1,
name: 'Foo',
can_approve_or_deny: true,
};
describe('<WorkflowApprovalListDenyButton />', () => {
test('should render button', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListDenyButton onDeny={() => {}} selectedItems={[]} />
);
expect(wrapper.find('button')).toHaveLength(1);
});
test('should invoke onDeny prop', () => {
const onDeny = jest.fn();
const wrapper = mountWithContexts(
<WorkflowApprovalListDenyButton
onDeny={onDeny}
selectedItems={[workflowApproval]}
/>
);
wrapper.find('button').simulate('click');
wrapper.update();
expect(onDeny).toHaveBeenCalled();
});
test('should disable button when no approve/deny permissions', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListDenyButton
onDeny={() => {}}
selectedItems={[{ ...workflowApproval, can_approve_or_deny: false }]}
/>
);
expect(wrapper.find('button[disabled]')).toHaveLength(1);
});
test('should render tooltip', () => {
const wrapper = mountWithContexts(
<WorkflowApprovalListDenyButton
onDeny={() => {}}
selectedItems={[workflowApproval]}
/>
);
expect(wrapper.find('Tooltip')).toHaveLength(1);
expect(wrapper.find('Tooltip').prop('content')).toEqual('Deny');
});
});

View File

@ -1,27 +1,26 @@
import React, { useCallback, useState } from 'react';
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, bool, func } from 'prop-types';
import {
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
Label,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import { WorkflowApproval } from '../../../types';
import { formatDateString } from '../../../util/dates';
import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(2, 40px);
const StatusCell = styled(DataListCell)`
@media screen and (min-width: 768px) {
display: flex;
justify-content: flex-end;
}
`;
const JobLabel = styled.b`
@ -35,7 +34,6 @@ function WorkflowApprovalListItem({
detailUrl,
i18n,
}) {
const [actionTaken, setActionTaken] = useState(false);
const labelId = `check-action-${workflowApproval.id}`;
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
@ -44,23 +42,25 @@ function WorkflowApprovalListItem({
workflowApproval.status === 'pending' &&
workflowApproval.approval_expiration
) {
return i18n._(
t`Expires on ${formatDateString(workflowApproval.approval_expiration)}`
return (
<Label>
{i18n._(
t`Expires on ${formatDateString(
workflowApproval.approval_expiration
)}`
)}
</Label>
);
}
if (
workflowApproval.status === 'pending' &&
!workflowApproval.approval_expiration
) {
return i18n._(t`Never expires`);
return <Label>{i18n._(t`Never expires`)}</Label>;
}
return <WorkflowApprovalStatus workflowApproval={workflowApproval} />;
};
const handleSuccesfulAction = useCallback(() => {
setActionTaken(true);
}, []);
return (
<DataListItem
key={workflowApproval.id}
@ -91,19 +91,9 @@ function WorkflowApprovalListItem({
</>
)}
</DataListCell>,
<DataListCell key="status">{getStatus()}</DataListCell>,
<StatusCell key="status">{getStatus()}</StatusCell>,
]}
/>
<DataListAction aria-label="actions" aria-labelledby={labelId}>
{workflowApproval.can_approve_or_deny && !actionTaken ? (
<WorkflowApprovalActionButtons
workflowApproval={workflowApproval}
onSuccessfulAction={handleSuccesfulAction}
/>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);

View File

@ -1,16 +1,17 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
import { WorkflowApprovalsAPI } from '../../../api';
import workflowApproval from '../data.workflowApproval.json';
jest.mock('../../../api/models/WorkflowApprovals');
describe('<WorkflowApprovalListItem />', () => {
test('action buttons shown to users with ability to approve/deny', () => {
const wrapper = mountWithContexts(
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('should display never expires status', () => {
wrapper = mountWithContexts(
<WorkflowApprovalListItem
isSelected={false}
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
@ -18,38 +19,83 @@ describe('<WorkflowApprovalListItem />', () => {
workflowApproval={workflowApproval}
/>
);
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
expect(wrapper.find('Label[children="Never expires"]').length).toBe(1);
});
test('action buttons hidden from users without ability to approve/deny', () => {
const wrapper = mountWithContexts(
test('should display timed out status', () => {
wrapper = mountWithContexts(
<WorkflowApprovalListItem
isSelected={false}
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
onSelect={() => {}}
workflowApproval={{ ...workflowApproval, can_approve_or_deny: false }}
workflowApproval={{
...workflowApproval,
status: 'failed',
timed_out: true,
}}
/>
);
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
expect(wrapper.find('Label[children="Timed out"]').length).toBe(1);
});
test('should hide action buttons after successful action', async () => {
WorkflowApprovalsAPI.approve.mockResolvedValue();
const wrapper = mountWithContexts(
test('should display canceled status', () => {
wrapper = mountWithContexts(
<WorkflowApprovalListItem
isSelected={false}
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
onSelect={() => {}}
workflowApproval={workflowApproval}
workflowApproval={{
...workflowApproval,
canceled_on: '2020-10-09T19:59:26.974046Z',
status: 'canceled',
}}
/>
);
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
await act(async () =>
wrapper.find('Button[aria-label="Approve"]').prop('onClick')()
expect(wrapper.find('Label[children="Canceled"]').length).toBe(1);
});
test('should display approved status', () => {
wrapper = mountWithContexts(
<WorkflowApprovalListItem
isSelected={false}
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
onSelect={() => {}}
workflowApproval={{
...workflowApproval,
status: 'successful',
summary_fields: {
...workflowApproval.summary_fields,
approved_or_denied_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
},
}}
/>
);
wrapper.update();
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalled();
expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
jest.clearAllMocks();
expect(wrapper.find('Label[children="Approved"]').length).toBe(1);
});
test('should display denied status', () => {
wrapper = mountWithContexts(
<WorkflowApprovalListItem
isSelected={false}
detailUrl={`/workflow_approvals/${workflowApproval.id}`}
onSelect={() => {}}
workflowApproval={{
...workflowApproval,
failed: true,
status: 'failed',
summary_fields: {
...workflowApproval.summary_fields,
approved_or_denied_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
},
}}
/>
);
expect(wrapper.find('Label[children="Denied"]').length).toBe(1);
});
});

View File

@ -1,115 +0,0 @@
import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import { Button, Tooltip } from '@patternfly/react-core';
import { CheckIcon, CloseIcon } from '@patternfly/react-icons';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
import { WorkflowApprovalsAPI } from '../../../api';
function WorkflowApprovalActionButtons({
workflowApproval,
icon,
i18n,
onSuccessfulAction,
}) {
const {
isLoading: approveApprovalLoading,
error: approveApprovalError,
request: approveApprovalNode,
} = useRequest(
useCallback(async () => {
await WorkflowApprovalsAPI.approve(workflowApproval.id);
if (onSuccessfulAction) {
onSuccessfulAction();
}
}, [onSuccessfulAction, workflowApproval.id]),
{}
);
const {
isLoading: denyApprovalLoading,
error: denyApprovalError,
request: denyApprovalNode,
} = useRequest(
useCallback(async () => {
await WorkflowApprovalsAPI.deny(workflowApproval.id);
if (onSuccessfulAction) {
onSuccessfulAction();
}
}, [onSuccessfulAction, workflowApproval.id]),
{}
);
const {
error: approveError,
dismissError: dismissApproveError,
} = useDismissableError(approveApprovalError);
const {
error: denyError,
dismissError: dismissDenyError,
} = useDismissableError(denyApprovalError);
return (
<>
<Tooltip content={i18n._(t`Approve`)} position="top">
<Button
isDisabled={approveApprovalLoading || denyApprovalLoading}
aria-label={i18n._(t`Approve`)}
variant={icon ? 'plain' : 'primary'}
onClick={approveApprovalNode}
>
{icon ? <CheckIcon /> : i18n._(t`Approve`)}
</Button>
</Tooltip>
<Tooltip content={i18n._(t`Deny`)} position="top">
<Button
isDisabled={approveApprovalLoading || denyApprovalLoading}
aria-label={i18n._(t`Deny`)}
variant={icon ? 'plain' : 'danger'}
onClick={denyApprovalNode}
>
{icon ? <CloseIcon /> : i18n._(t`Deny`)}
</Button>
</Tooltip>
{approveError && (
<AlertModal
isOpen={approveError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissApproveError}
>
{i18n._(t`Failed to approve this approval node.`)}
<ErrorDetail error={approveError} />
</AlertModal>
)}
{denyError && (
<AlertModal
isOpen={denyError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissDenyError}
>
{i18n._(t`Failed to deny this approval node.`)}
<ErrorDetail error={denyError} />
</AlertModal>
)}
</>
);
}
WorkflowApprovalActionButtons.propTypes = {
workflowApproval: PropTypes.shape({}).isRequired,
icon: PropTypes.bool,
onSuccessfulAction: PropTypes.func,
};
WorkflowApprovalActionButtons.defaultProps = {
icon: true,
onSuccessfulAction: () => {},
};
export default withI18n()(WorkflowApprovalActionButtons);

View File

@ -1,94 +0,0 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { WorkflowApprovalsAPI } from '../../../api';
import WorkflowApprovalActionButtons from './WorkflowApprovalActionButtons';
import workflowApproval from '../data.workflowApproval.json';
jest.mock('../../../api/models/WorkflowApprovals');
describe('<WorkflowApprovalActionButtons />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('initially renders succesfully with icons', () => {
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
);
expect(wrapper.find('CheckIcon').length).toBe(1);
expect(wrapper.find('CloseIcon').length).toBe(1);
expect(wrapper.find('Button[children="Approve"]').length).toBe(0);
expect(wrapper.find('Button[children="Deny"]').length).toBe(0);
});
test('initially renders succesfully without icons', () => {
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons
workflowApproval={workflowApproval}
icon={false}
/>
);
expect(wrapper.find('CheckIcon').length).toBe(0);
expect(wrapper.find('CloseIcon').length).toBe(0);
expect(wrapper.find('Button[children="Approve"]').length).toBe(1);
expect(wrapper.find('Button[children="Deny"]').length).toBe(1);
});
test('approving makes correct call with correct param', async () => {
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
);
await act(async () => wrapper.find('CheckIcon').simulate('click'));
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledWith(
workflowApproval.id
);
});
test('denying makes correct call with correct param', async () => {
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
);
await act(async () => wrapper.find('CloseIcon').simulate('click'));
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledWith(workflowApproval.id);
});
test('approval error shown', async () => {
WorkflowApprovalsAPI.approve.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/workflow_approvals/approve',
},
data: 'An error occurred',
status: 403,
},
})
);
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
);
expect(wrapper.find('AlertModal').length).toBe(0);
await act(async () => wrapper.find('CheckIcon').simulate('click'));
wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1);
});
test('denial error shown', async () => {
WorkflowApprovalsAPI.deny.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/workflow_approvals/deny',
},
data: 'An error occurred',
status: 403,
},
})
);
wrapper = mountWithContexts(
<WorkflowApprovalActionButtons workflowApproval={workflowApproval} />
);
expect(wrapper.find('AlertModal').length).toBe(0);
await act(async () => wrapper.find('CloseIcon').simulate('click'));
wrapper.update();
expect(wrapper.find('AlertModal').length).toBe(1);
});
});

View File

@ -2,7 +2,7 @@ import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Label, Tooltip } from '@patternfly/react-core';
import { InfoCircleIcon } from '@patternfly/react-icons';
import { CheckIcon, InfoCircleIcon } from '@patternfly/react-icons';
import { WorkflowApproval } from '../../../types';
import { formatDateString } from '../../../util/dates';
@ -35,7 +35,7 @@ function WorkflowApprovalStatus({ workflowApproval, i18n }) {
)}
position="top"
>
<Label color="red" icon={<InfoCircleIcon />}>
<Label variant="outline" color="red" icon={<InfoCircleIcon />}>
{i18n._(t`Denied`)}
</Label>
</Tooltip>
@ -52,7 +52,7 @@ function WorkflowApprovalStatus({ workflowApproval, i18n }) {
)}
position="top"
>
<Label color="green" icon={<InfoCircleIcon />}>
<Label variant="outline" color="green" icon={<CheckIcon />}>
{i18n._(t`Approved`)}
</Label>
</Tooltip>