mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Move approval action buttons from rows to to level list actions. UX updates to the display of the status.
This commit is contained in:
parent
ee7f73623f
commit
a9c3484387
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user