From 4beea35d9e8ea8c5c46daecb9fd67f5117522349 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 24 Mar 2022 13:22:12 -0400 Subject: [PATCH] Refactors workflow approval list toolbar and details acttions to add clarity. --- awx/ui/.eslintrc.json | 3 +- awx/ui/src/components/StatusIcon/icons.js | 3 + .../src/components/StatusLabel/StatusLabel.js | 13 +- .../StatusLabel/StatusLabel.test.js | 7 + .../WorkflowApprovalDetail.js | 97 +++++++----- .../WorkflowApprovalDetail.test.js | 71 +++++++-- .../WorkflowApprovalList.js | 115 ++++++++++---- .../WorkflowApprovalList.test.js | 86 +++++++++-- .../WorkflowApprovalListApproveButton.js | 77 --------- .../WorkflowApprovalListApproveButton.test.js | 56 ------- .../WorkflowApprovalListDenyButton.js | 77 --------- .../WorkflowApprovalListDenyButton.test.js | 53 ------- .../WorkflowApprovalListItem.js | 64 +++----- .../data.workflowApprovals.json | 71 +++++++++ .../shared/WorkflowApprovalControls.js | 146 ++++++++++++++++++ .../shared/WorkflowApprovalControls.test.js | 94 +++++++++++ .../shared/WorkflowApprovalStatus.js | 72 --------- .../shared/WorkflowApprovalStatus.test.js | 99 ------------ .../shared/WorkflowApprovalUtils.js | 61 ++++++++ .../shared/WorkflowApprovalUtils.test.js | 117 ++++++++++++++ 20 files changed, 813 insertions(+), 569 deletions(-) delete mode 100644 awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js delete mode 100644 awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.js delete mode 100644 awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.js delete mode 100644 awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.js create mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js create mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.test.js delete mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.js delete mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.js create mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.js create mode 100644 awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.test.js diff --git a/awx/ui/.eslintrc.json b/awx/ui/.eslintrc.json index 9d6b2c75b2..ca8352549e 100644 --- a/awx/ui/.eslintrc.json +++ b/awx/ui/.eslintrc.json @@ -95,7 +95,8 @@ "href", "modifier", "data-cy", - "fieldName" + "fieldName", + "splitButtonVariant" ], "ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "hh:mm AM/PM", "Twilio"], "ignoreComponent": [ diff --git a/awx/ui/src/components/StatusIcon/icons.js b/awx/ui/src/components/StatusIcon/icons.js index 6148c6945f..e91174661d 100644 --- a/awx/ui/src/components/StatusIcon/icons.js +++ b/awx/ui/src/components/StatusIcon/icons.js @@ -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, diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.js b/awx/ui/src/components/StatusLabel/StatusLabel.js index 1cbdd366a7..f7a91c8af2 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.js @@ -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 = () => ( ); @@ -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', diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.test.js b/awx/ui/src/components/StatusLabel/StatusLabel.test.js index 6eb1cf4b7c..4296f90d83 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.test.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.test.js @@ -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( + + ); + expect(wrapper.text()).toEqual('children'); + }); }); diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js index 504ec2751e..445bd99dd0 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js @@ -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 ( @@ -86,9 +114,9 @@ function WorkflowApprovalDetail({ workflowApproval }) { + {getDetailPendingLabel(workflowApproval)} + } dataCy="wa-detail-expires" /> @@ -96,9 +124,7 @@ function WorkflowApprovalDetail({ workflowApproval }) { {workflowApproval.status !== 'pending' && ( - } + value={} dataCy="wa-detail-status" /> )} @@ -169,31 +195,21 @@ function WorkflowApprovalDetail({ workflowApproval }) { /> - {workflowApproval.can_approve_or_deny && ( - <> - - - - )} + {workflowApproval.status === 'pending' && + workflowApproval.can_approve_or_deny && ( + + setIsKebabModalOpen(isOpen) + } + isKebabOpen={isKebabOpen} + /> + )} {workflowApproval.status !== 'pending' && - workflowApproval.summary_fields.user_capabilities && - workflowApproval.summary_fields.user_capabilities.delete && ( + workflowApproval.summary_fields?.user_capabilities?.delete && ( )} + {cancelError && ( + + {t`Failed to approve workflow approval.`} + + + )} {denyError && ( ', () => { ); 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('', () => { }} /> ); - 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('', () => { ); expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0); }); + test('only the delete button should render when approval is not pending', () => { + const wrapper = mountWithContexts( + + ); + 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('', () => { const wrapper = mountWithContexts( ); - 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('', () => { const wrapper = mountWithContexts( ); + 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('', () => { 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( ', () => { 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( + + ); + 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 () => { diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js index 7e44d3f7c8..020acbed7c 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js @@ -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() { , - + setIsKebabModalOpen(isOpen) + } + isKebabOpen={isKebabOpen} />, {t`Name`} - {t`Job`} + {t`Workflow Job`} {t`Started`} {t`Status`} @@ -250,17 +285,37 @@ function WorkflowApprovalsList() { )} - {actionError && ( + {approveError && ( - {approveApprovalError - ? t`Failed to approve one or more workflow approval.` - : t`Failed to deny one or more workflow approval.`} - + {t`Failed to approve one or more workflow approval.`} + + + )} + {cancelError && ( + + {t`Failed to cancel one or more workflow approval.`} + + + )} + {denyError && ( + + {t`Failed to deny one or more workflow approval.`} + )} diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js index 4b6bd69e83..840381236f 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js @@ -38,7 +38,7 @@ describe('', () => { }); 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('', () => { 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('', () => { ).toEqual(true); }); - test('should disable delete button', async () => { + test('WorkflowapprovalControls is inactive. Delete button is active', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + 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(); + }); + 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(); }); 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('', () => { 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('', () => { }); 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'); diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js deleted file mode 100644 index 4d3f96cb48..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js +++ /dev/null @@ -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 ? ( - - {t`Approve`} - - ) : ( - -
- -
-
- )} - - ); -} - -WorkflowApprovalListApproveButton.propTypes = { - onApprove: PropTypes.func.isRequired, - selectedItems: PropTypes.arrayOf(WorkflowApproval), -}; - -WorkflowApprovalListApproveButton.defaultProps = { - selectedItems: [], -}; - -export default WorkflowApprovalListApproveButton; diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.js deleted file mode 100644 index 949c07ec10..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.js +++ /dev/null @@ -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('', () => { - test('should render button', () => { - const wrapper = mountWithContexts( - {}} - selectedItems={[]} - /> - ); - expect(wrapper.find('button')).toHaveLength(1); - }); - - test('should invoke onApprove prop', () => { - const onApprove = jest.fn(); - const wrapper = mountWithContexts( - - ); - wrapper.find('button').simulate('click'); - wrapper.update(); - expect(onApprove).toHaveBeenCalled(); - }); - - test('should disable button when no approve/deny permissions', () => { - const wrapper = mountWithContexts( - {}} - selectedItems={[{ ...workflowApproval, can_approve_or_deny: false }]} - /> - ); - expect(wrapper.find('button[disabled]')).toHaveLength(1); - }); - - test('should render tooltip', () => { - const wrapper = mountWithContexts( - {}} - selectedItems={[workflowApproval]} - /> - ); - expect(wrapper.find('Tooltip')).toHaveLength(1); - expect(wrapper.find('Tooltip').prop('content')).toEqual('Approve'); - }); -}); diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.js deleted file mode 100644 index 52e9cfc387..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.js +++ /dev/null @@ -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 ? ( - - {t`Deny`} - - ) : ( - -
- -
-
- )} - - ); -} - -WorkflowApprovalListDenyButton.propTypes = { - onDeny: PropTypes.func.isRequired, - selectedItems: PropTypes.arrayOf(WorkflowApproval), -}; - -WorkflowApprovalListDenyButton.defaultProps = { - selectedItems: [], -}; - -export default WorkflowApprovalListDenyButton; diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.js deleted file mode 100644 index a799ecf208..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.js +++ /dev/null @@ -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('', () => { - test('should render button', () => { - const wrapper = mountWithContexts( - {}} selectedItems={[]} /> - ); - expect(wrapper.find('button')).toHaveLength(1); - }); - - test('should invoke onDeny prop', () => { - const onDeny = jest.fn(); - const wrapper = mountWithContexts( - - ); - wrapper.find('button').simulate('click'); - wrapper.update(); - expect(onDeny).toHaveBeenCalled(); - }); - - test('should disable button when no approve/deny permissions', () => { - const wrapper = mountWithContexts( - {}} - selectedItems={[{ ...workflowApproval, can_approve_or_deny: false }]} - /> - ); - expect(wrapper.find('button[disabled]')).toHaveLength(1); - }); - - test('should render tooltip', () => { - const wrapper = mountWithContexts( - {}} - selectedItems={[workflowApproval]} - /> - ); - expect(wrapper.find('Tooltip')).toHaveLength(1); - expect(wrapper.find('Tooltip').prop('content')).toEqual('Deny'); - }); -}); diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js index 7414001676..c475c468c8 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js @@ -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 ( - - ); - } - if ( - workflowApproval.status === 'pending' && - !workflowApproval.approval_expiration - ) { - return ; - } - return ; - }; - return ( - <> - {t`Job`} - {workflowJob && workflowJob?.id ? ( - - {`${workflowJob?.id} - ${workflowJob?.name}`} - - ) : ( - t`Deleted` - )} - + {workflowJob && workflowJob?.id ? ( + + {`${workflowJob?.id} - ${workflowJob?.name}`} + + ) : ( + t`Deleted` + )} {formatDateString(workflowApproval.started)} -
{getStatus()}
+ {workflowApproval.status === 'pending' ? ( + + {getPendingLabel(workflowApproval)} + + ) : ( + + )} ); diff --git a/awx/ui/src/screens/WorkflowApproval/data.workflowApprovals.json b/awx/ui/src/screens/WorkflowApproval/data.workflowApprovals.json index c21ec3df98..031983c610 100644 --- a/awx/ui/src/screens/WorkflowApproval/data.workflowApprovals.json +++ b/awx/ui/src/screens/WorkflowApproval/data.workflowApprovals.json @@ -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 } ] } diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js new file mode 100644 index 0000000000..90ae428182 --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js @@ -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 = [ + +
+ + {t`Deny`} + +
+
, + +
+ + {t`Cancel`} + +
+
, + ]; + if (isKebabified) { + dropdownItems.unshift( + +
+ {t`Approve`} +
+
+ ); + return dropdownItems; + } + + return ( + + + {t`Approve`} + , + ]} + data-cy="actions-kebab-toogle" + isDisabled={isApproveDenyOrCancelDisabled} + onToggle={onHandleToggleToolbarKebab} + /> + } + isOpen={isKebabOpen} + isPlain + ouiaId="actions-dropdown" + dropdownItems={dropdownItems} + /> + + ); +} +export default WorkflowApprovalControls; diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.test.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.test.js new file mode 100644 index 0000000000..8a66c041fd --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.test.js @@ -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(' 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( + + ); + 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( + + ); + expect(wrapper.find('WorkflowApprovalControls')).toHaveLength(1); + expect(wrapper.find('WorkflowApprovalControls').prop('isKebabOpen')).toBe( + true + ); + }); +}); + +describe(', 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( + + ); + 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( + + ); + 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); + }); +}); diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.js deleted file mode 100644 index ac0cdef5f1..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.js +++ /dev/null @@ -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 ; - } - - if (workflowApproval.canceled_on) { - return ; - } - - if (workflowApproval.status === 'failed' && workflowApproval.failed) { - return ( - - - - ); - } - - if (workflowApproval.status === 'successful') { - return ( - - - - ); - } - - return null; -} - -WorkflowApprovalStatus.defaultProps = { - workflowApproval: WorkflowApproval.isRequired, -}; - -export default WorkflowApprovalStatus; diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.js deleted file mode 100644 index 49bb99f58a..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.js +++ /dev/null @@ -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('', () => { - let wrapper; - - test('shows no expiration when approval status is pending and no approval_expiration', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.text()).toBe('Never expires'); - }); - - test('shows expiration date/time when approval status is pending and approval_expiration present', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.text()).toBe( - `Expires on ${formatDateString('2020-10-10T17:13:12.067947Z')}` - ); - }); - - test('shows when an approval has timed out', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('Label').text()).toBe('Timed out'); - }); - - test('shows when an approval has canceled', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('Label').text()).toBe('Canceled'); - }); - - test('shows when an approval has approved', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('Label').text()).toBe('Approved'); - }); - - test('shows when an approval has denied', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('Label').text()).toBe('Denied'); - }); -}); diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.js new file mode 100644 index 0000000000..110481e607 --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.js @@ -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)}`; +} diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.test.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.test.js new file mode 100644 index 0000000000..7f0c89e760 --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalUtils.test.js @@ -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('', () => { + 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.' + ); + }); +});