mirror of
https://github.com/ansible/awx.git
synced 2026-02-12 23:24:48 -03:30
Refactors workflow approval list toolbar and details acttions to add clarity.
This commit is contained in:
@@ -95,7 +95,8 @@
|
||||
"href",
|
||||
"modifier",
|
||||
"data-cy",
|
||||
"fieldName"
|
||||
"fieldName",
|
||||
"splitButtonVariant"
|
||||
],
|
||||
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "hh:mm AM/PM", "Twilio"],
|
||||
"ignoreComponent": [
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
MinusCircleIcon,
|
||||
InfoCircleIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
|
||||
const Spin = keyframes`
|
||||
@@ -23,6 +24,8 @@ const RunningIcon = styled(SyncAltIcon)`
|
||||
RunningIcon.displayName = 'RunningIcon';
|
||||
|
||||
const icons = {
|
||||
approved: CheckCircleIcon,
|
||||
denied: InfoCircleIcon,
|
||||
success: CheckCircleIcon,
|
||||
healthy: CheckCircleIcon,
|
||||
successful: CheckCircleIcon,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Label, Tooltip } from '@patternfly/react-core';
|
||||
import icons from '../StatusIcon/icons';
|
||||
|
||||
const colors = {
|
||||
approved: 'green',
|
||||
denied: 'red',
|
||||
success: 'green',
|
||||
successful: 'green',
|
||||
ok: 'green',
|
||||
@@ -17,14 +19,17 @@ const colors = {
|
||||
running: 'blue',
|
||||
pending: 'blue',
|
||||
skipped: 'blue',
|
||||
timedOut: 'red',
|
||||
waiting: 'grey',
|
||||
disabled: 'grey',
|
||||
canceled: 'orange',
|
||||
changed: 'orange',
|
||||
};
|
||||
|
||||
export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
export default function StatusLabel({ status, tooltipContent = '', children }) {
|
||||
const upperCaseStatus = {
|
||||
approved: t`Approved`,
|
||||
denied: t`Denied`,
|
||||
success: t`Success`,
|
||||
healthy: t`Healthy`,
|
||||
successful: t`Successful`,
|
||||
@@ -35,6 +40,7 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
running: t`Running`,
|
||||
pending: t`Pending`,
|
||||
skipped: t`Skipped'`,
|
||||
timedOut: t`Timed out`,
|
||||
waiting: t`Waiting`,
|
||||
disabled: t`Disabled`,
|
||||
canceled: t`Canceled`,
|
||||
@@ -46,7 +52,7 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
|
||||
const renderLabel = () => (
|
||||
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
|
||||
{label}
|
||||
{children || label}
|
||||
</Label>
|
||||
);
|
||||
|
||||
@@ -65,6 +71,8 @@ export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
|
||||
StatusLabel.propTypes = {
|
||||
status: oneOf([
|
||||
'approved',
|
||||
'denied',
|
||||
'success',
|
||||
'successful',
|
||||
'ok',
|
||||
@@ -75,6 +83,7 @@ StatusLabel.propTypes = {
|
||||
'running',
|
||||
'pending',
|
||||
'skipped',
|
||||
'timedOut',
|
||||
'waiting',
|
||||
'disabled',
|
||||
'canceled',
|
||||
|
||||
@@ -79,4 +79,11 @@ describe('StatusLabel', () => {
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||
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 { 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';
|
||||
import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import { formatDateString, secondsToHHMMSS } from 'util/dates';
|
||||
import { WorkflowApprovalsAPI } from 'api';
|
||||
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
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 }) {
|
||||
const { id: workflowApprovalId } = useParams();
|
||||
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
||||
const history = useHistory();
|
||||
const {
|
||||
request: deleteWorkflowApproval,
|
||||
@@ -61,13 +66,36 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
||||
const { error: denyError, dismissError: dismissDenyError } =
|
||||
useDismissableError(denyApprovalError);
|
||||
|
||||
const {
|
||||
error: cancelApprovalError,
|
||||
isLoading: isCancelLoading,
|
||||
request: cancelWorkflowApprovals,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
await WorkflowJobsAPI.cancel(
|
||||
workflowApproval.summary_fields.source_workflow_job.id
|
||||
);
|
||||
history.push(`/workflow_approvals/${workflowApprovalId}`);
|
||||
}, [workflowApproval, workflowApprovalId, history]),
|
||||
{}
|
||||
);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsKebabModalOpen(false);
|
||||
await cancelWorkflowApprovals();
|
||||
};
|
||||
|
||||
const { error: cancelError, dismissError: dismissCancelError } =
|
||||
useDismissableError(cancelApprovalError);
|
||||
|
||||
const sourceWorkflowJob =
|
||||
workflowApproval?.summary_fields?.source_workflow_job;
|
||||
|
||||
const sourceWorkflowJobTemplate =
|
||||
workflowApproval?.summary_fields?.workflow_job_template;
|
||||
|
||||
const isLoading = isDeleteLoading || isApproveLoading || isDenyLoading;
|
||||
const isLoading =
|
||||
isDeleteLoading || isApproveLoading || isDenyLoading || isCancelLoading;
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
@@ -86,9 +114,9 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
||||
<Detail
|
||||
label={t`Expires`}
|
||||
value={
|
||||
workflowApproval.approval_expiration
|
||||
? formatDateString(workflowApproval.approval_expiration)
|
||||
: t`Never`
|
||||
<StatusLabel status={workflowApproval.status}>
|
||||
{getDetailPendingLabel(workflowApproval)}
|
||||
</StatusLabel>
|
||||
}
|
||||
dataCy="wa-detail-expires"
|
||||
/>
|
||||
@@ -96,9 +124,7 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
||||
{workflowApproval.status !== 'pending' && (
|
||||
<Detail
|
||||
label={t`Status`}
|
||||
value={
|
||||
<WorkflowApprovalStatus workflowApproval={workflowApproval} />
|
||||
}
|
||||
value={<StatusLabel status={getStatus(workflowApproval)} />}
|
||||
dataCy="wa-detail-status"
|
||||
/>
|
||||
)}
|
||||
@@ -169,31 +195,21 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{workflowApproval.can_approve_or_deny && (
|
||||
<>
|
||||
<Button
|
||||
ouiaId={`${workflowApproval.id}-approve-button`}
|
||||
aria-label={t`Approve`}
|
||||
variant="primary"
|
||||
onClick={approveWorkflowApproval}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{t`Approve`}
|
||||
</Button>
|
||||
<Button
|
||||
ouiaId={`${workflowApproval.id}-deny-button`}
|
||||
aria-label={t`Deny`}
|
||||
variant="danger"
|
||||
onClick={denyWorkflowApproval}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{t`Deny`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{workflowApproval.status === 'pending' &&
|
||||
workflowApproval.can_approve_or_deny && (
|
||||
<WorkflowApprovalControls
|
||||
selected={[workflowApproval]}
|
||||
onHandleApprove={approveWorkflowApproval}
|
||||
onHandleDeny={denyWorkflowApproval}
|
||||
onHandleCancel={handleCancel}
|
||||
onHandleToggleToolbarKebab={(isOpen) =>
|
||||
setIsKebabModalOpen(isOpen)
|
||||
}
|
||||
isKebabOpen={isKebabOpen}
|
||||
/>
|
||||
)}
|
||||
{workflowApproval.status !== 'pending' &&
|
||||
workflowApproval.summary_fields.user_capabilities &&
|
||||
workflowApproval.summary_fields.user_capabilities.delete && (
|
||||
workflowApproval.summary_fields?.user_capabilities?.delete && (
|
||||
<DeleteButton
|
||||
name={workflowApproval.name}
|
||||
modalTitle={t`Delete Workflow Approval`}
|
||||
@@ -226,6 +242,17 @@ function WorkflowApprovalDetail({ workflowApproval }) {
|
||||
<ErrorDetail error={approveError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{cancelError && (
|
||||
<AlertModal
|
||||
isOpen={cancelError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissCancelError}
|
||||
>
|
||||
{t`Failed to approve workflow approval.`}
|
||||
<ErrorDetail error={cancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{denyError && (
|
||||
<AlertModal
|
||||
isOpen={denyError}
|
||||
|
||||
@@ -50,8 +50,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
);
|
||||
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
|
||||
assertDetail('Elapsed', '00:00:22');
|
||||
expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(1);
|
||||
expect(wrapper.find('Button[aria-label="Deny"]').length).toBe(1);
|
||||
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should show expiration date/time', () => {
|
||||
@@ -126,9 +125,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('WorkflowApprovalStatus Label').text()).toBe(
|
||||
'Approved'
|
||||
);
|
||||
expect(wrapper.find('StatusLabel').text()).toBe('Approved');
|
||||
});
|
||||
|
||||
test('should show actor when available', () => {
|
||||
@@ -160,6 +157,20 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
);
|
||||
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 () => {
|
||||
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
|
||||
@@ -168,12 +179,8 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
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')();
|
||||
wrapper.find('DropdownToggleAction').invoke('onClick')();
|
||||
});
|
||||
expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1);
|
||||
await waitForElement(
|
||||
@@ -198,12 +205,16 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
|
||||
);
|
||||
await act(async () => wrapper.find('Toggle').prop('onToggle')(true));
|
||||
wrapper.update();
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'WorkflowApprovalDetail Button[aria-label="Deny"]'
|
||||
'WorkflowApprovalDetail DropdownItem[ouiaId="workflow-deny-button"]'
|
||||
);
|
||||
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);
|
||||
await waitForElement(
|
||||
@@ -244,7 +255,7 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
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(
|
||||
<WorkflowApprovalDetail
|
||||
workflowApproval={{
|
||||
@@ -256,10 +267,44 @@ describe('<WorkflowApprovalDetail />', () => {
|
||||
start: false,
|
||||
},
|
||||
},
|
||||
can_approve_or_deny: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 { t, Plural } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { WorkflowApprovalsAPI } from 'api';
|
||||
import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
@@ -20,8 +20,7 @@ import useSelected from 'hooks/useSelected';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import WorkflowApprovalListItem from './WorkflowApprovalListItem';
|
||||
import useWsWorkflowApprovals from './useWsWorkflowApprovals';
|
||||
import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
|
||||
import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
|
||||
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
|
||||
|
||||
const QS_CONFIG = getQSConfig('workflow_approvals', {
|
||||
page: 1,
|
||||
@@ -32,6 +31,7 @@ const QS_CONFIG = getQSConfig('workflow_approvals', {
|
||||
function WorkflowApprovalsList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
result: { results, count, relatedSearchableKeys, searchableKeys },
|
||||
@@ -45,8 +45,12 @@ function WorkflowApprovalsList() {
|
||||
WorkflowApprovalsAPI.read(params),
|
||||
WorkflowApprovalsAPI.readOptions(),
|
||||
]);
|
||||
const dataWithModifiedName = response.data.results.map((i) => {
|
||||
i.name = `${i.summary_fields.source_workflow_job.id} - ${i.name}`;
|
||||
return i;
|
||||
});
|
||||
return {
|
||||
results: response.data.results,
|
||||
results: dataWithModifiedName,
|
||||
count: response.data.count,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
@@ -141,12 +145,47 @@ function WorkflowApprovalsList() {
|
||||
);
|
||||
|
||||
const handleDeny = async () => {
|
||||
setIsKebabModalOpen(false);
|
||||
await denyWorkflowApprovals();
|
||||
clearSelected();
|
||||
};
|
||||
|
||||
const { error: actionError, dismissError: dismissActionError } =
|
||||
useDismissableError(approveApprovalError || denyApprovalError);
|
||||
const {
|
||||
error: cancelApprovalError,
|
||||
isLoading: isCancelLoading,
|
||||
request: cancelWorkflowApprovals,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async () =>
|
||||
Promise.all(
|
||||
selected.map(({ summary_fields }) =>
|
||||
WorkflowJobsAPI.cancel(summary_fields.source_workflow_job.id)
|
||||
)
|
||||
),
|
||||
[selected]
|
||||
),
|
||||
{}
|
||||
);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsKebabModalOpen(false);
|
||||
await cancelWorkflowApprovals();
|
||||
clearSelected();
|
||||
};
|
||||
|
||||
const { error: approveError, dismissError: dismissApproveError } =
|
||||
useDismissableError(approveApprovalError);
|
||||
const { error: cancelError, dismissError: dismissCancelError } =
|
||||
useDismissableError(cancelApprovalError);
|
||||
const { error: denyError, dismissError: dismissDenyError } =
|
||||
useDismissableError(denyApprovalError);
|
||||
|
||||
const isLoading =
|
||||
isWorkflowApprovalsLoading ||
|
||||
isDeleteLoading ||
|
||||
isApproveLoading ||
|
||||
isDenyLoading ||
|
||||
isCancelLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,12 +193,7 @@ function WorkflowApprovalsList() {
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={
|
||||
isWorkflowApprovalsLoading ||
|
||||
isDeleteLoading ||
|
||||
isApproveLoading ||
|
||||
isDenyLoading
|
||||
}
|
||||
hasContentLoading={isLoading}
|
||||
items={workflowApprovals}
|
||||
itemCount={count}
|
||||
pluralizedItemName={t`Workflow Approvals`}
|
||||
@@ -185,15 +219,16 @@ function WorkflowApprovalsList() {
|
||||
onSelectAll={selectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<WorkflowApprovalListApproveButton
|
||||
key="approve"
|
||||
onApprove={handleApprove}
|
||||
selectedItems={selected}
|
||||
/>,
|
||||
<WorkflowApprovalListDenyButton
|
||||
key="deny"
|
||||
onDeny={handleDeny}
|
||||
selectedItems={selected}
|
||||
<WorkflowApprovalControls
|
||||
key="approvalControls"
|
||||
onHandleApprove={handleApprove}
|
||||
selected={selected}
|
||||
onHandleDeny={handleDeny}
|
||||
onHandleCancel={handleCancel}
|
||||
onHandleToggleToolbarKebab={(isOpen) =>
|
||||
setIsKebabModalOpen(isOpen)
|
||||
}
|
||||
isKebabOpen={isKebabOpen}
|
||||
/>,
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
@@ -218,7 +253,7 @@ function WorkflowApprovalsList() {
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell>{t`Job`}</HeaderCell>
|
||||
<HeaderCell>{t`Workflow Job`}</HeaderCell>
|
||||
<HeaderCell sortKey="started">{t`Started`}</HeaderCell>
|
||||
<HeaderCell>{t`Status`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
@@ -250,17 +285,37 @@ function WorkflowApprovalsList() {
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{actionError && (
|
||||
{approveError && (
|
||||
<AlertModal
|
||||
isOpen={actionError}
|
||||
isOpen={approveError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissActionError}
|
||||
onClose={dismissApproveError}
|
||||
>
|
||||
{approveApprovalError
|
||||
? t`Failed to approve one or more workflow approval.`
|
||||
: t`Failed to deny one or more workflow approval.`}
|
||||
<ErrorDetail error={actionError} />
|
||||
{t`Failed to approve one or more workflow approval.`}
|
||||
<ErrorDetail error={approveError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{cancelError && (
|
||||
<AlertModal
|
||||
isOpen={cancelError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissCancelError}
|
||||
>
|
||||
{t`Failed to cancel one or more workflow approval.`}
|
||||
<ErrorDetail error={cancelError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{denyError && (
|
||||
<AlertModal
|
||||
isOpen={denyError}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissDenyError}
|
||||
>
|
||||
{t`Failed to deny one or more workflow approval.`}
|
||||
<ErrorDetail error={denyError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('<WorkflowApprovalList />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(3);
|
||||
expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should select workflow approval when checked', async () => {
|
||||
@@ -69,7 +69,7 @@ describe('<WorkflowApprovalList />', () => {
|
||||
wrapper.update();
|
||||
|
||||
const items = wrapper.find('WorkflowApprovalListItem');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items).toHaveLength(4);
|
||||
items.forEach((item) => {
|
||||
expect(item.prop('isSelected')).toEqual(true);
|
||||
});
|
||||
@@ -79,19 +79,64 @@ describe('<WorkflowApprovalList />', () => {
|
||||
).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 () => {
|
||||
wrapper = mountWithContexts(<WorkflowApprovalList />);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('WorkflowApprovalListItem').at(2).invoke('onSelect')();
|
||||
wrapper.find('WorkflowApprovalListItem').at(3).invoke('onSelect')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual(
|
||||
true
|
||||
expect(
|
||||
wrapper
|
||||
.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.update();
|
||||
await act(async () => {
|
||||
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
|
||||
});
|
||||
expect(
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -129,14 +181,22 @@ describe('<WorkflowApprovalList />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
expect(WorkflowApprovalsAPI.read).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('WorkflowApprovalListItem').at(1).invoke('onSelect')();
|
||||
});
|
||||
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.find('ToolbarDeleteButton').invoke('onDelete')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
|
||||
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 { t } from '@lingui/macro';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { Label } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { WorkflowApproval } from 'types';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
|
||||
|
||||
const JobLabel = styled.b`
|
||||
margin-right: 24px;
|
||||
`;
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import {
|
||||
getPendingLabel,
|
||||
getStatus,
|
||||
getTooltip,
|
||||
} from '../shared/WorkflowApprovalUtils';
|
||||
|
||||
function WorkflowApprovalListItem({
|
||||
workflowApproval,
|
||||
@@ -24,28 +22,6 @@ function WorkflowApprovalListItem({
|
||||
const labelId = `check-action-${workflowApproval.id}`;
|
||||
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 (
|
||||
<Tr id={`workflow-approval-row-${workflowApproval.id}`}>
|
||||
<Td
|
||||
@@ -62,22 +38,28 @@ function WorkflowApprovalListItem({
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<>
|
||||
<JobLabel>{t`Job`}</JobLabel>
|
||||
{workflowJob && workflowJob?.id ? (
|
||||
<Link to={`/jobs/workflow/${workflowJob?.id}`}>
|
||||
{`${workflowJob?.id} - ${workflowJob?.name}`}
|
||||
</Link>
|
||||
) : (
|
||||
t`Deleted`
|
||||
)}
|
||||
</>
|
||||
{workflowJob && workflowJob?.id ? (
|
||||
<Link to={`/jobs/workflow/${workflowJob?.id}`}>
|
||||
{`${workflowJob?.id} - ${workflowJob?.name}`}
|
||||
</Link>
|
||||
) : (
|
||||
t`Deleted`
|
||||
)}
|
||||
</Td>
|
||||
<Td dataLabel={t`Started`}>
|
||||
{formatDateString(workflowApproval.started)}
|
||||
</Td>
|
||||
<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>
|
||||
</Tr>
|
||||
);
|
||||
|
||||
@@ -222,6 +222,77 @@
|
||||
"can_approve_or_deny": false,
|
||||
"approval_expiration": null,
|
||||
"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