mirror of
https://github.com/ansible/awx.git
synced 2026-02-19 04:00:06 -03:30
Refactors workflow approval list toolbar and details acttions to add clarity.
This commit is contained in:
@@ -95,7 +95,8 @@
|
|||||||
"href",
|
"href",
|
||||||
"modifier",
|
"modifier",
|
||||||
"data-cy",
|
"data-cy",
|
||||||
"fieldName"
|
"fieldName",
|
||||||
|
"splitButtonVariant"
|
||||||
],
|
],
|
||||||
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "hh:mm AM/PM", "Twilio"],
|
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "hh:mm AM/PM", "Twilio"],
|
||||||
"ignoreComponent": [
|
"ignoreComponent": [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
|
InfoCircleIcon,
|
||||||
} from '@patternfly/react-icons';
|
} from '@patternfly/react-icons';
|
||||||
|
|
||||||
const Spin = keyframes`
|
const Spin = keyframes`
|
||||||
@@ -23,6 +24,8 @@ const RunningIcon = styled(SyncAltIcon)`
|
|||||||
RunningIcon.displayName = 'RunningIcon';
|
RunningIcon.displayName = 'RunningIcon';
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
|
approved: CheckCircleIcon,
|
||||||
|
denied: InfoCircleIcon,
|
||||||
success: CheckCircleIcon,
|
success: CheckCircleIcon,
|
||||||
healthy: CheckCircleIcon,
|
healthy: CheckCircleIcon,
|
||||||
successful: CheckCircleIcon,
|
successful: CheckCircleIcon,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Label, Tooltip } from '@patternfly/react-core';
|
|||||||
import icons from '../StatusIcon/icons';
|
import icons from '../StatusIcon/icons';
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
|
approved: 'green',
|
||||||
|
denied: 'red',
|
||||||
success: 'green',
|
success: 'green',
|
||||||
successful: 'green',
|
successful: 'green',
|
||||||
ok: 'green',
|
ok: 'green',
|
||||||
@@ -17,14 +19,17 @@ const colors = {
|
|||||||
running: 'blue',
|
running: 'blue',
|
||||||
pending: 'blue',
|
pending: 'blue',
|
||||||
skipped: 'blue',
|
skipped: 'blue',
|
||||||
|
timedOut: 'red',
|
||||||
waiting: 'grey',
|
waiting: 'grey',
|
||||||
disabled: 'grey',
|
disabled: 'grey',
|
||||||
canceled: 'orange',
|
canceled: 'orange',
|
||||||
changed: 'orange',
|
changed: 'orange',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StatusLabel({ status, tooltipContent = '' }) {
|
export default function StatusLabel({ status, tooltipContent = '', children }) {
|
||||||
const upperCaseStatus = {
|
const upperCaseStatus = {
|
||||||
|
approved: t`Approved`,
|
||||||
|
denied: t`Denied`,
|
||||||
success: t`Success`,
|
success: t`Success`,
|
||||||
healthy: t`Healthy`,
|
healthy: t`Healthy`,
|
||||||
successful: t`Successful`,
|
successful: t`Successful`,
|
||||||
@@ -35,6 +40,7 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
|||||||
running: t`Running`,
|
running: t`Running`,
|
||||||
pending: t`Pending`,
|
pending: t`Pending`,
|
||||||
skipped: t`Skipped'`,
|
skipped: t`Skipped'`,
|
||||||
|
timedOut: t`Timed out`,
|
||||||
waiting: t`Waiting`,
|
waiting: t`Waiting`,
|
||||||
disabled: t`Disabled`,
|
disabled: t`Disabled`,
|
||||||
canceled: t`Canceled`,
|
canceled: t`Canceled`,
|
||||||
@@ -46,7 +52,7 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
|||||||
|
|
||||||
const renderLabel = () => (
|
const renderLabel = () => (
|
||||||
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
||||||
{label}
|
{children || label}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,6 +71,8 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
|||||||
|
|
||||||
StatusLabel.propTypes = {
|
StatusLabel.propTypes = {
|
||||||
status: oneOf([
|
status: oneOf([
|
||||||
|
'approved',
|
||||||
|
'denied',
|
||||||
'success',
|
'success',
|
||||||
'successful',
|
'successful',
|
||||||
'ok',
|
'ok',
|
||||||
@@ -75,6 +83,7 @@ StatusLabel.propTypes = {
|
|||||||
'running',
|
'running',
|
||||||
'pending',
|
'pending',
|
||||||
'skipped',
|
'skipped',
|
||||||
|
'timedOut',
|
||||||
'waiting',
|
'waiting',
|
||||||
'disabled',
|
'disabled',
|
||||||
'canceled',
|
'canceled',
|
||||||
|
|||||||
@@ -79,4 +79,11 @@ describe('StatusLabel', () => {
|
|||||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||||
expect(wrapper.find('Tooltip').prop('content')).toEqual('Foo');
|
expect(wrapper.find('Tooltip').prop('content')).toEqual('Foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render children', () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
<StatusLabel tooltipContent="Foo" status="success" children="children" />
|
||||||
|
);
|
||||||
|
expect(wrapper.text()).toEqual('children');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link, useHistory, useParams } from 'react-router-dom';
|
import { Link, useHistory, useParams } from 'react-router-dom';
|
||||||
import { Button } from '@patternfly/react-core';
|
|
||||||
import AlertModal from 'components/AlertModal';
|
import AlertModal from 'components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from 'components/Card';
|
import { CardBody, CardActionsRow } from 'components/Card';
|
||||||
import DeleteButton from 'components/DeleteButton';
|
import DeleteButton from 'components/DeleteButton';
|
||||||
import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
|
import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
|
||||||
import ErrorDetail from 'components/ErrorDetail';
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
import { formatDateString, secondsToHHMMSS } from 'util/dates';
|
import { formatDateString, secondsToHHMMSS } from 'util/dates';
|
||||||
import { WorkflowApprovalsAPI } from 'api';
|
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import { WorkflowApproval } from 'types';
|
import { WorkflowApproval } from 'types';
|
||||||
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
import {
|
||||||
|
getDetailPendingLabel,
|
||||||
|
getStatus,
|
||||||
|
} from '../shared/WorkflowApprovalUtils';
|
||||||
|
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
|
||||||
|
|
||||||
function WorkflowApprovalDetail({ workflowApproval }) {
|
function WorkflowApprovalDetail({ workflowApproval }) {
|
||||||
const { id: workflowApprovalId } = useParams();
|
const { id: workflowApprovalId } = useParams();
|
||||||
|
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const {
|
const {
|
||||||
request: deleteWorkflowApproval,
|
request: deleteWorkflowApproval,
|
||||||
@@ -61,13 +66,36 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
const { error: denyError, dismissError: dismissDenyError } =
|
const { error: denyError, dismissError: dismissDenyError } =
|
||||||
useDismissableError(denyApprovalError);
|
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 handleCancel = async () => {
|
||||||
|
setIsKebabModalOpen(false);
|
||||||
|
await cancelWorkflowApprovals();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: cancelError, dismissError: dismissCancelError } =
|
||||||
|
useDismissableError(cancelApprovalError);
|
||||||
|
|
||||||
const sourceWorkflowJob =
|
const sourceWorkflowJob =
|
||||||
workflowApproval?.summary_fields?.source_workflow_job;
|
workflowApproval?.summary_fields?.source_workflow_job;
|
||||||
|
|
||||||
const sourceWorkflowJobTemplate =
|
const sourceWorkflowJobTemplate =
|
||||||
workflowApproval?.summary_fields?.workflow_job_template;
|
workflowApproval?.summary_fields?.workflow_job_template;
|
||||||
|
|
||||||
const isLoading = isDeleteLoading || isApproveLoading || isDenyLoading;
|
const isLoading =
|
||||||
|
isDeleteLoading || isApproveLoading || isDenyLoading || isCancelLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -86,9 +114,9 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
<Detail
|
<Detail
|
||||||
label={t`Expires`}
|
label={t`Expires`}
|
||||||
value={
|
value={
|
||||||
workflowApproval.approval_expiration
|
<StatusLabel status={workflowApproval.status}>
|
||||||
? formatDateString(workflowApproval.approval_expiration)
|
{getDetailPendingLabel(workflowApproval)}
|
||||||
: t`Never`
|
</StatusLabel>
|
||||||
}
|
}
|
||||||
dataCy="wa-detail-expires"
|
dataCy="wa-detail-expires"
|
||||||
/>
|
/>
|
||||||
@@ -96,9 +124,7 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
{workflowApproval.status !== 'pending' && (
|
{workflowApproval.status !== 'pending' && (
|
||||||
<Detail
|
<Detail
|
||||||
label={t`Status`}
|
label={t`Status`}
|
||||||
value={
|
value={<StatusLabel status={getStatus(workflowApproval)} />}
|
||||||
<WorkflowApprovalStatus workflowApproval={workflowApproval} />
|
|
||||||
}
|
|
||||||
dataCy="wa-detail-status"
|
dataCy="wa-detail-status"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -169,31 +195,21 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
/>
|
/>
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{workflowApproval.can_approve_or_deny && (
|
{workflowApproval.status === 'pending' &&
|
||||||
<>
|
workflowApproval.can_approve_or_deny && (
|
||||||
<Button
|
<WorkflowApprovalControls
|
||||||
ouiaId={`${workflowApproval.id}-approve-button`}
|
selected={[workflowApproval]}
|
||||||
aria-label={t`Approve`}
|
onHandleApprove={approveWorkflowApproval}
|
||||||
variant="primary"
|
onHandleDeny={denyWorkflowApproval}
|
||||||
onClick={approveWorkflowApproval}
|
onHandleCancel={handleCancel}
|
||||||
isDisabled={isLoading}
|
onHandleToggleToolbarKebab={(isOpen) =>
|
||||||
>
|
setIsKebabModalOpen(isOpen)
|
||||||
{t`Approve`}
|
}
|
||||||
</Button>
|
isKebabOpen={isKebabOpen}
|
||||||
<Button
|
/>
|
||||||
ouiaId={`${workflowApproval.id}-deny-button`}
|
)}
|
||||||
aria-label={t`Deny`}
|
|
||||||
variant="danger"
|
|
||||||
onClick={denyWorkflowApproval}
|
|
||||||
isDisabled={isLoading}
|
|
||||||
>
|
|
||||||
{t`Deny`}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{workflowApproval.status !== 'pending' &&
|
{workflowApproval.status !== 'pending' &&
|
||||||
workflowApproval.summary_fields.user_capabilities &&
|
workflowApproval.summary_fields?.user_capabilities?.delete && (
|
||||||
workflowApproval.summary_fields.user_capabilities.delete && (
|
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
name={workflowApproval.name}
|
name={workflowApproval.name}
|
||||||
modalTitle={t`Delete Workflow Approval`}
|
modalTitle={t`Delete Workflow Approval`}
|
||||||
@@ -226,6 +242,17 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
|||||||
<ErrorDetail error={approveError} />
|
<ErrorDetail error={approveError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
|
{cancelError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={cancelError}
|
||||||
|
variant="error"
|
||||||
|
title={t`Error!`}
|
||||||
|
onClose={dismissCancelError}
|
||||||
|
>
|
||||||
|
{t`Failed to approve workflow approval.`}
|
||||||
|
<ErrorDetail error={cancelError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
{denyError && (
|
{denyError && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={denyError}
|
isOpen={denyError}
|
||||||
|
|||||||
@@ -50,8 +50,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
);
|
);
|
||||||
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
|
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
|
||||||
assertDetail('Elapsed', '00:00:22');
|
assertDetail('Elapsed', '00:00:22');
|
||||||
expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(1);
|
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
|
||||||
expect(wrapper.find('Button[aria-label="Deny"]').length).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show expiration date/time', () => {
|
test('should show expiration date/time', () => {
|
||||||
@@ -126,9 +125,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('WorkflowApprovalStatus Label').text()).toBe(
|
expect(wrapper.find('StatusLabel').text()).toBe('Approved');
|
||||||
'Approved'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show actor when available', () => {
|
test('should show actor when available', () => {
|
||||||
@@ -160,6 +157,20 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
);
|
);
|
||||||
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0);
|
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
test('only the delete button should render when approval is not pending', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<WorkflowApprovalDetail
|
||||||
|
workflowApproval={{
|
||||||
|
...workflowApproval,
|
||||||
|
can_approve_or_deny: true,
|
||||||
|
status: 'successful',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('WorkflowApprovalControls').length).toBe(0);
|
||||||
|
expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('DeleteButton').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('Error dialog shown for failed approval', async () => {
|
test('Error dialog shown for failed approval', async () => {
|
||||||
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
|
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
|
||||||
@@ -168,12 +179,8 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||||
);
|
);
|
||||||
await waitForElement(
|
|
||||||
wrapper,
|
|
||||||
'WorkflowApprovalDetail Button[aria-label="Approve"]'
|
|
||||||
);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('Button[aria-label="Approve"]').invoke('onClick')();
|
wrapper.find('DropdownToggleAction').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
|
||||||
await waitForElement(
|
await waitForElement(
|
||||||
@@ -198,12 +205,16 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||||
);
|
);
|
||||||
|
await act(async () => wrapper.find('Toggle').prop('onToggle')(true));
|
||||||
|
wrapper.update();
|
||||||
await waitForElement(
|
await waitForElement(
|
||||||
wrapper,
|
wrapper,
|
||||||
'WorkflowApprovalDetail Button[aria-label="Deny"]'
|
'WorkflowApprovalDetail DropdownItem[ouiaId="workflow-deny-button"]'
|
||||||
);
|
);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('Button[aria-label="Deny"]').invoke('onClick')();
|
wrapper
|
||||||
|
.find('DropdownItem[ouiaId="workflow-deny-button"]')
|
||||||
|
.invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1);
|
||||||
await waitForElement(
|
await waitForElement(
|
||||||
@@ -244,7 +255,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
expect(wrapper.find('DeleteButton').length).toBe(0);
|
expect(wrapper.find('DeleteButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete button should be hidden when job is pending', () => {
|
test('delete button should be hidden when job is pending and approve, action buttons should render', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<WorkflowApprovalDetail
|
<WorkflowApprovalDetail
|
||||||
workflowApproval={{
|
workflowApproval={{
|
||||||
@@ -256,10 +267,44 @@ describe('<WorkflowApprovalDetail />', () => {
|
|||||||
start: false,
|
start: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
can_approve_or_deny: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wrapper.find('DeleteButton').length).toBe(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 () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<WorkflowApprovalDetail
|
||||||
|
workflowApproval={mockWorkflowApprovals.results[1]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('DeleteButton').length).toBe(1);
|
||||||
|
expect(wrapper.find('DeleteButton').prop('isDisabled')).toBe(false);
|
||||||
|
expect(wrapper.find('WorkflowApprovalControls').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Error dialog shown for failed deletion', async () => {
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { WorkflowApprovalsAPI } from 'api';
|
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
|
||||||
import PaginatedTable, {
|
import PaginatedTable, {
|
||||||
HeaderRow,
|
HeaderRow,
|
||||||
HeaderCell,
|
HeaderCell,
|
||||||
@@ -20,8 +20,7 @@ import useSelected from 'hooks/useSelected';
|
|||||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||||
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
||||||
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
||||||
import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
|
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
|
||||||
import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
|
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -32,6 +31,7 @@ const QS_CONFIG = getQSConfig('workflow_approvals', {
|
|||||||
function WorkflowApprovalsList() {
|
function WorkflowApprovalsList() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -45,8 +45,12 @@ function WorkflowApprovalsList() {
|
|||||||
WorkflowApprovalsAPI.read(params),
|
WorkflowApprovalsAPI.read(params),
|
||||||
WorkflowApprovalsAPI.readOptions(),
|
WorkflowApprovalsAPI.readOptions(),
|
||||||
]);
|
]);
|
||||||
|
const dataWithModifiedName = response.data.results.map((i) => {
|
||||||
|
i.name = `${i.summary_fields.source_workflow_job.id} - ${i.name}`;
|
||||||
|
return i;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
results: response.data.results,
|
results: dataWithModifiedName,
|
||||||
count: response.data.count,
|
count: response.data.count,
|
||||||
relatedSearchableKeys: (
|
relatedSearchableKeys: (
|
||||||
actionsResponse?.data?.related_search_fields || []
|
actionsResponse?.data?.related_search_fields || []
|
||||||
@@ -141,12 +145,47 @@ function WorkflowApprovalsList() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDeny = async () => {
|
const handleDeny = async () => {
|
||||||
|
setIsKebabModalOpen(false);
|
||||||
await denyWorkflowApprovals();
|
await denyWorkflowApprovals();
|
||||||
clearSelected();
|
clearSelected();
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error: actionError, dismissError: dismissActionError } =
|
const {
|
||||||
useDismissableError(approveApprovalError || denyApprovalError);
|
error: cancelApprovalError,
|
||||||
|
isLoading: isCancelLoading,
|
||||||
|
request: cancelWorkflowApprovals,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async () =>
|
||||||
|
Promise.all(
|
||||||
|
selected.map(({ summary_fields }) =>
|
||||||
|
WorkflowJobsAPI.cancel(summary_fields.source_workflow_job.id)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[selected]
|
||||||
|
),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setIsKebabModalOpen(false);
|
||||||
|
await cancelWorkflowApprovals();
|
||||||
|
clearSelected();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: approveError, dismissError: dismissApproveError } =
|
||||||
|
useDismissableError(approveApprovalError);
|
||||||
|
const { error: cancelError, dismissError: dismissCancelError } =
|
||||||
|
useDismissableError(cancelApprovalError);
|
||||||
|
const { error: denyError, dismissError: dismissDenyError } =
|
||||||
|
useDismissableError(denyApprovalError);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isWorkflowApprovalsLoading ||
|
||||||
|
isDeleteLoading ||
|
||||||
|
isApproveLoading ||
|
||||||
|
isDenyLoading ||
|
||||||
|
isCancelLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,12 +193,7 @@ function WorkflowApprovalsList() {
|
|||||||
<Card>
|
<Card>
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={
|
hasContentLoading={isLoading}
|
||||||
isWorkflowApprovalsLoading ||
|
|
||||||
isDeleteLoading ||
|
|
||||||
isApproveLoading ||
|
|
||||||
isDenyLoading
|
|
||||||
}
|
|
||||||
items={workflowApprovals}
|
items={workflowApprovals}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={t`Workflow Approvals`}
|
pluralizedItemName={t`Workflow Approvals`}
|
||||||
@@ -185,15 +219,16 @@ function WorkflowApprovalsList() {
|
|||||||
onSelectAll={selectAll}
|
onSelectAll={selectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
<WorkflowApprovalListApproveButton
|
<WorkflowApprovalControls
|
||||||
key="approve"
|
key="approvalControls"
|
||||||
onApprove={handleApprove}
|
onHandleApprove={handleApprove}
|
||||||
selectedItems={selected}
|
selected={selected}
|
||||||
/>,
|
onHandleDeny={handleDeny}
|
||||||
<WorkflowApprovalListDenyButton
|
onHandleCancel={handleCancel}
|
||||||
key="deny"
|
onHandleToggleToolbarKebab={(isOpen) =>
|
||||||
onDeny={handleDeny}
|
setIsKebabModalOpen(isOpen)
|
||||||
selectedItems={selected}
|
}
|
||||||
|
isKebabOpen={isKebabOpen}
|
||||||
/>,
|
/>,
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
@@ -218,7 +253,7 @@ function WorkflowApprovalsList() {
|
|||||||
headerRow={
|
headerRow={
|
||||||
<HeaderRow qsConfig={QS_CONFIG}>
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||||
<HeaderCell>{t`Job`}</HeaderCell>
|
<HeaderCell>{t`Workflow Job`}</HeaderCell>
|
||||||
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
|
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
|
||||||
<HeaderCell>{t`Status`}</HeaderCell>
|
<HeaderCell>{t`Status`}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
@@ -250,17 +285,37 @@ function WorkflowApprovalsList() {
|
|||||||
<ErrorDetail error={deletionError} />
|
<ErrorDetail error={deletionError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
{actionError && (
|
{approveError && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={actionError}
|
isOpen={approveError}
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t`Error!`}
|
title={t`Error!`}
|
||||||
onClose={dismissActionError}
|
onClose={dismissApproveError}
|
||||||
>
|
>
|
||||||
{approveApprovalError
|
{t`Failed to approve one or more workflow approval.`}
|
||||||
? t`Failed to approve one or more workflow approval.`
|
<ErrorDetail error={approveError} />
|
||||||
: t`Failed to deny one or more workflow approval.`}
|
</AlertModal>
|
||||||
<ErrorDetail error={actionError} />
|
)}
|
||||||
|
{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>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(3);
|
expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should select workflow approval when checked', async () => {
|
test('should select workflow approval when checked', async () => {
|
||||||
@@ -69,7 +69,7 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const items = wrapper.find('WorkflowApprovalListItem');
|
const items = wrapper.find('WorkflowApprovalListItem');
|
||||||
expect(items).toHaveLength(3);
|
expect(items).toHaveLength(4);
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
expect(item.prop('isSelected')).toEqual(true);
|
expect(item.prop('isSelected')).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -79,19 +79,64 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should disable delete button', async () => {
|
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 () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
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 () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('WorkflowApprovalListItem').at(2).invoke('onSelect')();
|
wrapper.find('WorkflowApprovalListItem').at(3).invoke('onSelect')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
expect(
|
||||||
expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual(
|
wrapper
|
||||||
true
|
.find('WorkflowApprovalControls')
|
||||||
|
.find('DropdownToggle')
|
||||||
|
.prop('isDisabled')
|
||||||
|
).toEqual(true);
|
||||||
|
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||||
|
false
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,10 +150,17 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await act(async () => {
|
expect(
|
||||||
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
|
wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')
|
||||||
});
|
).toEqual(false);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
|
||||||
|
);
|
||||||
expect(WorkflowApprovalsAPI.destroy).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,14 +181,22 @@ describe('<WorkflowApprovalList />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(WorkflowApprovalsAPI.read).toHaveBeenCalledTimes(1);
|
expect(WorkflowApprovalsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')
|
||||||
|
).toEqual(false);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
wrapper.update();
|
||||||
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
|
await act(async () =>
|
||||||
});
|
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
|
||||||
|
);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const modal = wrapper.find('Modal');
|
const modal = wrapper.find('Modal');
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
|
||||||
import React, { useContext } from '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 }) {
|
|
||||||
const { isKebabified } = useContext(KebabifiedContext);
|
|
||||||
|
|
||||||
const renderTooltip = () => {
|
|
||||||
if (selectedItems.length === 0) {
|
|
||||||
return t`Select a row to approve`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsUnableToApprove = selectedItems
|
|
||||||
.filter(cannotApprove)
|
|
||||||
.map((item) => item.name)
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
if (selectedItems.some(cannotApprove)) {
|
|
||||||
return t`You are unable to act on the following workflow approvals: ${itemsUnableToApprove}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return t`Approve`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled =
|
|
||||||
selectedItems.length === 0 || selectedItems.some(cannotApprove);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isKebabified ? (
|
|
||||||
<DropdownItem
|
|
||||||
key="approve"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
component="button"
|
|
||||||
onClick={onApprove}
|
|
||||||
ouiaId="workflow-approval-approve-dropdown-item"
|
|
||||||
>
|
|
||||||
{t`Approve`}
|
|
||||||
</DropdownItem>
|
|
||||||
) : (
|
|
||||||
<Tooltip content={renderTooltip()} position="top">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
ouiaId="workflow-approval-approve-button"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t`Approve`}
|
|
||||||
variant="primary"
|
|
||||||
onClick={onApprove}
|
|
||||||
>
|
|
||||||
{t`Approve`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkflowApprovalListApproveButton.propTypes = {
|
|
||||||
onApprove: PropTypes.func.isRequired,
|
|
||||||
selectedItems: PropTypes.arrayOf(WorkflowApproval),
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkflowApprovalListApproveButton.defaultProps = {
|
|
||||||
selectedItems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkflowApprovalListApproveButton;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
|
||||||
import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
|
|
||||||
|
|
||||||
const workflowApproval = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Foo',
|
|
||||||
can_approve_or_deny: true,
|
|
||||||
url: '/api/v2/workflow_approvals/218/',
|
|
||||||
};
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
|
||||||
import React, { useContext } from '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 }) {
|
|
||||||
const { isKebabified } = useContext(KebabifiedContext);
|
|
||||||
|
|
||||||
const renderTooltip = () => {
|
|
||||||
if (selectedItems.length === 0) {
|
|
||||||
return t`Select a row to deny`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsUnableToDeny = selectedItems
|
|
||||||
.filter(cannotDeny)
|
|
||||||
.map((item) => item.name)
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
if (selectedItems.some(cannotDeny)) {
|
|
||||||
return t`You are unable to act on the following workflow approvals: ${itemsUnableToDeny}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return t`Deny`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled =
|
|
||||||
selectedItems.length === 0 || selectedItems.some(cannotDeny);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isKebabified ? (
|
|
||||||
<DropdownItem
|
|
||||||
key="deny"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
component="button"
|
|
||||||
onClick={onDeny}
|
|
||||||
ouiaId="workflow-approval-deny-dropdown-item"
|
|
||||||
>
|
|
||||||
{t`Deny`}
|
|
||||||
</DropdownItem>
|
|
||||||
) : (
|
|
||||||
<Tooltip content={renderTooltip()} position="top">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
ouiaId="workflow-approval-deny-button"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t`Deny`}
|
|
||||||
variant="danger"
|
|
||||||
onClick={onDeny}
|
|
||||||
>
|
|
||||||
{t`Deny`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkflowApprovalListDenyButton.propTypes = {
|
|
||||||
onDeny: PropTypes.func.isRequired,
|
|
||||||
selectedItems: PropTypes.arrayOf(WorkflowApproval),
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkflowApprovalListDenyButton.defaultProps = {
|
|
||||||
selectedItems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkflowApprovalListDenyButton;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
|
||||||
import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
|
|
||||||
|
|
||||||
const workflowApproval = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Foo',
|
|
||||||
can_approve_or_deny: true,
|
|
||||||
url: '/api/v2/workflow_approvals/218/',
|
|
||||||
};
|
|
||||||
|
|
||||||
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,18 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { string, bool, func } from 'prop-types';
|
import { string, bool, func } from 'prop-types';
|
||||||
import { Label } from '@patternfly/react-core';
|
|
||||||
import { Tr, Td } from '@patternfly/react-table';
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { WorkflowApproval } from 'types';
|
import { WorkflowApproval } from 'types';
|
||||||
import { formatDateString } from 'util/dates';
|
import { formatDateString } from 'util/dates';
|
||||||
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
import {
|
||||||
const JobLabel = styled.b`
|
getPendingLabel,
|
||||||
margin-right: 24px;
|
getStatus,
|
||||||
`;
|
getTooltip,
|
||||||
|
} from '../shared/WorkflowApprovalUtils';
|
||||||
|
|
||||||
function WorkflowApprovalListItem({
|
function WorkflowApprovalListItem({
|
||||||
workflowApproval,
|
workflowApproval,
|
||||||
@@ -24,28 +22,6 @@ function WorkflowApprovalListItem({
|
|||||||
const labelId = `check-action-${workflowApproval.id}`;
|
const labelId = `check-action-${workflowApproval.id}`;
|
||||||
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
|
const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
|
||||||
|
|
||||||
const getStatus = () => {
|
|
||||||
if (
|
|
||||||
workflowApproval.status === 'pending' &&
|
|
||||||
workflowApproval.approval_expiration
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Label>
|
|
||||||
{t`Expires on ${formatDateString(
|
|
||||||
workflowApproval.approval_expiration
|
|
||||||
)}`}
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
workflowApproval.status === 'pending' &&
|
|
||||||
!workflowApproval.approval_expiration
|
|
||||||
) {
|
|
||||||
return <Label>{t`Never expires`}</Label>;
|
|
||||||
}
|
|
||||||
return <WorkflowApprovalStatus workflowApproval={workflowApproval} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
|
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
|
||||||
<Td
|
<Td
|
||||||
@@ -62,22 +38,28 @@ function WorkflowApprovalListItem({
|
|||||||
</Link>
|
</Link>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<>
|
{workflowJob && workflowJob?.id ? (
|
||||||
<JobLabel>{t`Job`}</JobLabel>
|
<Link to={`/jobs/workflow/${workflowJob?.id}`}>
|
||||||
{workflowJob && workflowJob?.id ? (
|
{`${workflowJob?.id} - ${workflowJob?.name}`}
|
||||||
<Link to={`/jobs/workflow/${workflowJob?.id}`}>
|
</Link>
|
||||||
{`${workflowJob?.id} - ${workflowJob?.name}`}
|
) : (
|
||||||
</Link>
|
t`Deleted`
|
||||||
) : (
|
)}
|
||||||
t`Deleted`
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Td>
|
</Td>
|
||||||
<Td dataLabel={t`Started`}>
|
<Td dataLabel={t`Started`}>
|
||||||
{formatDateString(workflowApproval.started)}
|
{formatDateString(workflowApproval.started)}
|
||||||
</Td>
|
</Td>
|
||||||
<Td dataLabel={t`Status`}>
|
<Td dataLabel={t`Status`}>
|
||||||
<div>{getStatus()}</div>
|
{workflowApproval.status === 'pending' ? (
|
||||||
|
<StatusLabel status={workflowApproval.status}>
|
||||||
|
{getPendingLabel(workflowApproval)}
|
||||||
|
</StatusLabel>
|
||||||
|
) : (
|
||||||
|
<StatusLabel
|
||||||
|
tooltipContent={getTooltip(workflowApproval)}
|
||||||
|
status={getStatus(workflowApproval)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -222,6 +222,77 @@
|
|||||||
"can_approve_or_deny": false,
|
"can_approve_or_deny": false,
|
||||||
"approval_expiration": null,
|
"approval_expiration": null,
|
||||||
"timed_out": false
|
"timed_out": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 220,
|
||||||
|
"type": "workflow_approval",
|
||||||
|
"url": "/api/v2/workflow_approvals/218/",
|
||||||
|
"related": {
|
||||||
|
"created_by": "/api/v2/users/1/",
|
||||||
|
"unified_job_template": "/api/v2/workflow_approval_templates/10/",
|
||||||
|
"source_workflow_job": "/api/v2/workflow_jobs/216/",
|
||||||
|
"workflow_approval_template": "/api/v2/workflow_approval_templates/10/",
|
||||||
|
"approve": "/api/v2/workflow_approvals/218/approve/",
|
||||||
|
"deny": "/api/v2/workflow_approvals/218/deny/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"workflow_job_template": {
|
||||||
|
"id": 9,
|
||||||
|
"name": "Approval @ 9:15:26 AM",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"workflow_job": {
|
||||||
|
"id": 216,
|
||||||
|
"name": "Approval @ 9:15:26 AM",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"workflow_approval_template": {
|
||||||
|
"id": 10,
|
||||||
|
"name": "approval copy",
|
||||||
|
"description": "",
|
||||||
|
"timeout": 0
|
||||||
|
},
|
||||||
|
"unified_job_template": {
|
||||||
|
"id": 10,
|
||||||
|
"name": "approval copy",
|
||||||
|
"description": "",
|
||||||
|
"unified_job_type": "workflow_approval"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"delete": true,
|
||||||
|
"start": true
|
||||||
|
},
|
||||||
|
"source_workflow_job": {
|
||||||
|
"id": 216,
|
||||||
|
"name": "Approval @ 9:15:26 AM",
|
||||||
|
"description": "",
|
||||||
|
"status": "running",
|
||||||
|
"failed": false,
|
||||||
|
"elapsed": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2020-10-09T17:13:12.067947Z",
|
||||||
|
"modified": "2020-10-09T17:13:12.068147Z",
|
||||||
|
"name": "approval",
|
||||||
|
"description": "description of approval",
|
||||||
|
"unified_job_template": 10,
|
||||||
|
"launch_type": "workflow",
|
||||||
|
"status": "successful",
|
||||||
|
"failed": false,
|
||||||
|
"started": "2020-10-09T17:13:12.067947Z",
|
||||||
|
"finished": null,
|
||||||
|
"canceled_on": null,
|
||||||
|
"elapsed": 22.879029,
|
||||||
|
"job_explanation": "",
|
||||||
|
"can_approve_or_deny": true,
|
||||||
|
"approval_expiration": null,
|
||||||
|
"timed_out": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { Label, Tooltip } from '@patternfly/react-core';
|
|
||||||
import { CheckIcon, InfoCircleIcon } from '@patternfly/react-icons';
|
|
||||||
import { WorkflowApproval } from 'types';
|
|
||||||
import { formatDateString } from 'util/dates';
|
|
||||||
|
|
||||||
function WorkflowApprovalStatus({ workflowApproval }) {
|
|
||||||
if (workflowApproval.status === 'pending') {
|
|
||||||
return workflowApproval.approval_expiration
|
|
||||||
? t`Expires on ${formatDateString(workflowApproval.approval_expiration)}`
|
|
||||||
: t`Never expires`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowApproval.timed_out) {
|
|
||||||
return <Label color="red">{t`Timed out`}</Label>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowApproval.canceled_on) {
|
|
||||||
return <Label color="red">{t`Canceled`}</Label>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowApproval.status === 'failed' && workflowApproval.failed) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
workflowApproval.summary_fields?.approved_or_denied_by?.username
|
|
||||||
? t`Denied by ${
|
|
||||||
workflowApproval.summary_fields.approved_or_denied_by.username
|
|
||||||
} - ${formatDateString(workflowApproval.finished)}`
|
|
||||||
: t`Denied - ${formatDateString(
|
|
||||||
workflowApproval.finished
|
|
||||||
)}. See the Activity Stream for more information.`
|
|
||||||
}
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<Label variant="outline" color="red" icon={<InfoCircleIcon />}>
|
|
||||||
{t`Denied`}
|
|
||||||
</Label>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowApproval.status === 'successful') {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
workflowApproval.summary_fields?.approved_or_denied_by?.username
|
|
||||||
? t`Approved by ${
|
|
||||||
workflowApproval.summary_fields.approved_or_denied_by.username
|
|
||||||
} - ${formatDateString(workflowApproval.finished)}`
|
|
||||||
: t`Approved - ${formatDateString(
|
|
||||||
workflowApproval.finished
|
|
||||||
)}. See the Activity Stream for more information.`
|
|
||||||
}
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<Label variant="outline" color="green" icon={<CheckIcon />}>
|
|
||||||
{t`Approved`}
|
|
||||||
</Label>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkflowApprovalStatus.defaultProps = {
|
|
||||||
workflowApproval: WorkflowApproval.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkflowApprovalStatus;
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { formatDateString } from 'util/dates';
|
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
|
||||||
import WorkflowApprovalStatus from './WorkflowApprovalStatus';
|
|
||||||
import mockWorkflowApprovals from '../data.workflowApprovals.json';
|
|
||||||
|
|
||||||
const workflowApproval = mockWorkflowApprovals.results[0];
|
|
||||||
|
|
||||||
describe('<WorkflowApprovalStatus />', () => {
|
|
||||||
let wrapper;
|
|
||||||
|
|
||||||
test('shows no expiration when approval status is pending and no approval_expiration', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus workflowApproval={workflowApproval} />
|
|
||||||
);
|
|
||||||
expect(wrapper.text()).toBe('Never expires');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows expiration date/time when approval status is pending and approval_expiration present', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
approval_expiration: '2020-10-10T17:13:12.067947Z',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.text()).toBe(
|
|
||||||
`Expires on ${formatDateString('2020-10-10T17:13:12.067947Z')}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows when an approval has timed out', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
status: 'failed',
|
|
||||||
timed_out: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('Label').text()).toBe('Timed out');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows when an approval has canceled', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
status: 'canceled',
|
|
||||||
canceled_on: '2020-10-10T17:13:12.067947Z',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('Label').text()).toBe('Canceled');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows when an approval has approved', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
summary_fields: {
|
|
||||||
...workflowApproval.summary_fields,
|
|
||||||
approved_or_denied_by: {
|
|
||||||
id: 1,
|
|
||||||
username: 'Foobar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 'successful',
|
|
||||||
finished: '2020-10-10T17:13:12.067947Z',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('Label').text()).toBe('Approved');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows when an approval has denied', () => {
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<WorkflowApprovalStatus
|
|
||||||
workflowApproval={{
|
|
||||||
...workflowApproval,
|
|
||||||
summary_fields: {
|
|
||||||
...workflowApproval.summary_fields,
|
|
||||||
approved_or_denied_by: {
|
|
||||||
id: 1,
|
|
||||||
username: 'Foobar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 'failed',
|
|
||||||
finished: '2020-10-10T17:13:12.067947Z',
|
|
||||||
failed: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('Label').text()).toBe('Denied');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { formatDateString } from 'util/dates';
|
||||||
|
|
||||||
|
export function getTooltip(workflowApproval) {
|
||||||
|
if (workflowApproval.status === 'successful') {
|
||||||
|
if (workflowApproval.summary_fields?.approved_or_denied_by?.username) {
|
||||||
|
return t`Approved by ${
|
||||||
|
workflowApproval.summary_fields.approved_or_denied_by.username
|
||||||
|
} - ${formatDateString(workflowApproval.finished)}`;
|
||||||
|
}
|
||||||
|
return t`Approved - ${formatDateString(
|
||||||
|
workflowApproval.finished
|
||||||
|
)}. See the Activity Stream for more information.`;
|
||||||
|
}
|
||||||
|
if (workflowApproval.status === 'failed' && workflowApproval.failed) {
|
||||||
|
if (workflowApproval.summary_fields?.approved_or_denied_by?.username) {
|
||||||
|
return t`Denied by ${
|
||||||
|
workflowApproval.summary_fields.approved_or_denied_by.username
|
||||||
|
} - ${formatDateString(workflowApproval.finished)}`;
|
||||||
|
}
|
||||||
|
return t`Denied - ${formatDateString(
|
||||||
|
workflowApproval.finished
|
||||||
|
)}. See the Activity Stream for more information.`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatus(workflowApproval) {
|
||||||
|
if (workflowApproval.timed_out) {
|
||||||
|
return 'timedOut';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowApproval.canceled_on) {
|
||||||
|
return 'canceled';
|
||||||
|
}
|
||||||
|
if (workflowApproval.status === 'failed' && workflowApproval.failed) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
if (workflowApproval.status === 'successful') {
|
||||||
|
return 'approved';
|
||||||
|
}
|
||||||
|
return workflowApproval.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingLabel(workflowApproval) {
|
||||||
|
if (!workflowApproval.approval_expiration) {
|
||||||
|
return t`Never expires`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t`Expires on ${formatDateString(
|
||||||
|
workflowApproval.approval_expiration
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDetailPendingLabel(workflowApproval) {
|
||||||
|
if (!workflowApproval.approval_expiration) {
|
||||||
|
return t`Never`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDateString(workflowApproval.approval_expiration)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { I18nProvider } from '@lingui/react';
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
|
import { en } from 'make-plural/plurals';
|
||||||
|
import { formatDateString } from 'util/dates';
|
||||||
|
import {
|
||||||
|
getPendingLabel,
|
||||||
|
getStatus,
|
||||||
|
getTooltip,
|
||||||
|
} from '../shared/WorkflowApprovalUtils';
|
||||||
|
import mockWorkflowApprovals from '../data.workflowApprovals.json';
|
||||||
|
|
||||||
|
const workflowApproval = mockWorkflowApprovals.results[0];
|
||||||
|
i18n.loadLocaleData('en', { plurals: en });
|
||||||
|
|
||||||
|
async function activate() {
|
||||||
|
const { messages } = await import(`../../../locales/${'en'}/messages.js`);
|
||||||
|
i18n.load('en', messages);
|
||||||
|
i18n.activate('en');
|
||||||
|
}
|
||||||
|
activate();
|
||||||
|
|
||||||
|
describe('<WorkflowApproval />', () => {
|
||||||
|
test('shows no expiration when approval status is pending and no approval_expiration', () => {
|
||||||
|
expect(getPendingLabel(workflowApproval)).toEqual('Never expires');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows expiration date/time when approval status is pending and approval_expiration present', () => {
|
||||||
|
workflowApproval.approval_expiration = '2020-10-10T17:13:12.067947Z';
|
||||||
|
|
||||||
|
expect(getPendingLabel(workflowApproval)).toEqual(
|
||||||
|
`Expires on 10/10/2020, 5:13:12 PM`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows when an approval has timed out', () => {
|
||||||
|
workflowApproval.status = 'failed';
|
||||||
|
workflowApproval.timed_out = true;
|
||||||
|
expect(getStatus(workflowApproval)).toEqual('timedOut');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows when an approval has canceled', () => {
|
||||||
|
workflowApproval.status = 'canceled';
|
||||||
|
workflowApproval.canceled_on = '2020-10-10T17:13:12.067947Z';
|
||||||
|
workflowApproval.timed_out = false;
|
||||||
|
expect(getStatus(workflowApproval)).toEqual('canceled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows when an approval has beeen approved', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: { id: 1, username: 'Foobar' },
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'successful';
|
||||||
|
workflowApproval.canceled_on = '';
|
||||||
|
workflowApproval.finished = '';
|
||||||
|
expect(getStatus(workflowApproval)).toEqual('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows when an approval has timed out', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: { id: 1, username: 'Foobar' },
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'failed';
|
||||||
|
workflowApproval.finished = '';
|
||||||
|
workflowApproval.failed = true;
|
||||||
|
expect(getStatus(workflowApproval)).toEqual('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows correct approved tooltip with user', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: { id: 1, username: 'Foobar' },
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'successful';
|
||||||
|
workflowApproval.finished = '2020-10-10T17:13:12.067947Z';
|
||||||
|
expect(getTooltip(workflowApproval)).toEqual(
|
||||||
|
'Approved by Foobar - 10/10/2020, 5:13:12 PM'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('shows correct approved tooltip without user', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: {},
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'successful';
|
||||||
|
workflowApproval.finished = '2020-10-10T17:13:12.067947Z';
|
||||||
|
expect(getTooltip(workflowApproval)).toEqual(
|
||||||
|
'Approved - 10/10/2020, 5:13:12 PM. See the Activity Stream for more information.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows correct denial tooltip with user', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: { id: 1, username: 'Foobar' },
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'failed';
|
||||||
|
workflowApproval.finished = '2020-10-10T17:13:12.067947Z';
|
||||||
|
workflowApproval.failed = true;
|
||||||
|
expect(getTooltip(workflowApproval)).toEqual(
|
||||||
|
'Denied by Foobar - 10/10/2020, 5:13:12 PM'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('shows correct denial tooltip without user', () => {
|
||||||
|
workflowApproval.summary_fields = {
|
||||||
|
...workflowApproval.summary_fields,
|
||||||
|
approved_or_denied_by: {},
|
||||||
|
};
|
||||||
|
workflowApproval.status = 'failed';
|
||||||
|
workflowApproval.finished = '2020-10-10T17:13:12.067947Z';
|
||||||
|
workflowApproval.failed = true;
|
||||||
|
expect(getTooltip(workflowApproval)).toEqual(
|
||||||
|
'Denied - 10/10/2020, 5:13:12 PM. See the Activity Stream for more information.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user