diff --git a/awx/ui_next/src/routeConfig.js b/awx/ui_next/src/routeConfig.js index b35e360fbe..0b9e0591fa 100644 --- a/awx/ui_next/src/routeConfig.js +++ b/awx/ui_next/src/routeConfig.js @@ -44,6 +44,11 @@ function getRouteConfig(i18n) { path: '/schedules', screen: Schedules, }, + { + title: i18n._(t`Workflow Approvals`), + path: '/workflow_approvals', + screen: WorkflowApprovals, + }, ], }, { @@ -127,11 +132,6 @@ function getRouteConfig(i18n) { path: '/applications', screen: Applications, }, - { - title: i18n._(t`Workflow Approvals`), - path: '/workflow_approvals', - screen: WorkflowApprovals, - }, ], }, { diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx index c34a84fe02..2496cbb4ee 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link, useHistory, useParams } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; import AlertModal from '../../../components/AlertModal'; import { CardBody, CardActionsRow } from '../../../components/Card'; import DeleteButton from '../../../components/DeleteButton'; @@ -11,19 +12,19 @@ import { UserDateDetail, } from '../../../components/DetailList'; import ErrorDetail from '../../../components/ErrorDetail'; -import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons'; import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus'; import { formatDateString, secondsToHHMMSS } from '../../../util/dates'; import { WorkflowApprovalsAPI } from '../../../api'; import useRequest, { useDismissableError } from '../../../util/useRequest'; +import { WorkflowApproval } from '../../../types'; function WorkflowApprovalDetail({ i18n, workflowApproval }) { const { id: workflowApprovalId } = useParams(); const history = useHistory(); const { request: deleteWorkflowApproval, - isLoading, - error: deleteError, + isLoading: isDeleteLoading, + error: deleteApprovalError, } = useRequest( useCallback(async () => { await WorkflowApprovalsAPI.destroy(workflowApprovalId); @@ -31,7 +32,44 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) { }, [workflowApprovalId, history]) ); - const { error, dismissError } = useDismissableError(deleteError); + const { + error: deleteError, + dismissError: dismissDeleteError, + } = useDismissableError(deleteApprovalError); + + const { + error: approveApprovalError, + isLoading: isApproveLoading, + request: approveWorkflowApproval, + } = useRequest( + useCallback(async () => { + await WorkflowApprovalsAPI.approve(workflowApprovalId); + history.push(`/workflow_approvals/${workflowApprovalId}`); + }, [workflowApprovalId, history]), + {} + ); + + const { + error: approveError, + dismissError: dismissApproveError, + } = useDismissableError(approveApprovalError); + + const { + error: denyApprovalError, + isLoading: isDenyLoading, + request: denyWorkflowApproval, + } = useRequest( + useCallback(async () => { + await WorkflowApprovalsAPI.deny(workflowApprovalId); + history.push(`/workflow_approvals/${workflowApprovalId}`); + }, [workflowApprovalId, history]), + {} + ); + + const { + error: denyError, + dismissError: dismissDenyError, + } = useDismissableError(denyApprovalError); const sourceWorkflowJob = workflowApproval?.summary_fields?.source_workflow_job; @@ -39,9 +77,7 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) { const sourceWorkflowJobTemplate = workflowApproval?.summary_fields?.workflow_job_template; - const handleSuccesfulAction = useCallback(() => { - history.push(`/workflow_approvals/${workflowApprovalId}`); - }, [history, workflowApprovalId]); + const isLoading = isDeleteLoading || isApproveLoading || isDenyLoading; return ( @@ -135,11 +171,24 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) { {workflowApproval.can_approve_or_deny && ( - + <> + + + )} {workflowApproval.summary_fields.user_capabilities && workflowApproval.summary_fields.user_capabilities.delete && ( @@ -153,19 +202,45 @@ function WorkflowApprovalDetail({ i18n, workflowApproval }) { )} - {error && ( + {deleteError && ( {i18n._(t`Failed to delete workflow approval.`)} - + + + )} + {approveError && ( + + {i18n._(t`Failed to approve workflow approval.`)} + + + )} + {denyError && ( + + {i18n._(t`Failed to deny workflow approval.`)} + )} ); } +WorkflowApprovalDetail.defaultProps = { + workflowApproval: WorkflowApproval.isRequired, +}; + export default withI18n()(WorkflowApprovalDetail); diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx index f288d5bc60..9ea6f0bfa4 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx @@ -48,7 +48,8 @@ describe('', () => { ); assertDetail('Last Modified', formatDateString(workflowApproval.modified)); assertDetail('Elapsed', '00:00:22'); - expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(1); + expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(1); + expect(wrapper.find('Button[aria-label="Deny"]').length).toBe(1); expect(wrapper.find('DeleteButton').length).toBe(1); }); @@ -159,6 +160,66 @@ describe('', () => { expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0); }); + test('Error dialog shown for failed approval', async () => { + WorkflowApprovalsAPI.approve.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + const wrapper = mountWithContexts( + + ); + await waitForElement( + wrapper, + 'WorkflowApprovalDetail Button[aria-label="Approve"]' + ); + await act(async () => { + wrapper.find('Button[aria-label="Approve"]').invoke('onClick')(); + }); + expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); + + test('Error dialog shown for failed denial', async () => { + WorkflowApprovalsAPI.deny.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + const wrapper = mountWithContexts( + + ); + await waitForElement( + wrapper, + 'WorkflowApprovalDetail Button[aria-label="Deny"]' + ); + await act(async () => { + wrapper.find('Button[aria-label="Deny"]').invoke('onClick')(); + }); + expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); + test('delete button should be hidden when user cannot delete', () => { const wrapper = mountWithContexts( { + return Promise.all( + selected.map(({ id }) => WorkflowApprovalsAPI.approve(id)) + ); + }, [selected]), + {} + ); + + const handleApprove = async () => { + await approveWorkflowApprovals(); + setSelected([]); + }; + + const { + error: approveError, + dismissError: dismissApproveError, + } = useDismissableError(approveApprovalError); + + const { + error: denyApprovalError, + isLoading: isDenyLoading, + request: denyWorkflowApprovals, + } = useRequest( + useCallback(async () => { + return Promise.all( + selected.map(({ id }) => WorkflowApprovalsAPI.deny(id)) + ); + }, [selected]), + {} + ); + + const handleDeny = async () => { + await denyWorkflowApprovals(); + setSelected([]); + }; + + const { + error: denyError, + dismissError: dismissDenyError, + } = useDismissableError(denyApprovalError); + return ( <> , + , - - {i18n._(t`Failed to delete one or more workflow approval.`)} - - + {deletionError && ( + + {i18n._(t`Failed to delete one or more workflow approval.`)} + + + )} + {approveError && ( + + {i18n._(t`Failed to approve one or more workflow approval.`)} + + + )} + {denyError && ( + + {i18n._(t`Failed to deny one or more workflow approval.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.jsx new file mode 100644 index 0000000000..a61a03c47d --- /dev/null +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.jsx @@ -0,0 +1,76 @@ +import React, { useContext } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; +import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { KebabifiedContext } from '../../../contexts/Kebabified'; +import { WorkflowApproval } from '../../../types'; + +function cannotApprove(item) { + return !item.can_approve_or_deny; +} + +function WorkflowApprovalListApproveButton({ onApprove, selectedItems, i18n }) { + const { isKebabified } = useContext(KebabifiedContext); + + const renderTooltip = () => { + if (selectedItems.length === 0) { + return i18n._(t`Select a row to approve`); + } + + const itemsUnableToApprove = selectedItems + .filter(cannotApprove) + .map(item => item.name) + .join(', '); + + if (selectedItems.some(cannotApprove)) { + return i18n._( + t`You are unable to act on the following workflow approvals: ${itemsUnableToApprove}` + ); + } + + return i18n._(t`Approve`); + }; + + const isDisabled = + selectedItems.length === 0 || selectedItems.some(cannotApprove); + + return ( + <> + {isKebabified ? ( + + {i18n._(t`Approve`)} + + ) : ( + +
+ +
+
+ )} + + ); +} + +WorkflowApprovalListApproveButton.propTypes = { + onApprove: PropTypes.func.isRequired, + selectedItems: PropTypes.arrayOf(WorkflowApproval), +}; + +WorkflowApprovalListApproveButton.defaultProps = { + selectedItems: [], +}; + +export default withI18n()(WorkflowApprovalListApproveButton); diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.jsx new file mode 100644 index 0000000000..0930b58bbc --- /dev/null +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton'; + +const workflowApproval = { + id: 1, + name: 'Foo', + can_approve_or_deny: true, +}; + +describe('', () => { + 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_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.jsx new file mode 100644 index 0000000000..cd07b52547 --- /dev/null +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.jsx @@ -0,0 +1,76 @@ +import React, { useContext } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; +import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { KebabifiedContext } from '../../../contexts/Kebabified'; +import { WorkflowApproval } from '../../../types'; + +function cannotDeny(item) { + return !item.can_approve_or_deny; +} + +function WorkflowApprovalListDenyButton({ onDeny, selectedItems, i18n }) { + const { isKebabified } = useContext(KebabifiedContext); + + const renderTooltip = () => { + if (selectedItems.length === 0) { + return i18n._(t`Select a row to deny`); + } + + const itemsUnableToDeny = selectedItems + .filter(cannotDeny) + .map(item => item.name) + .join(', '); + + if (selectedItems.some(cannotDeny)) { + return i18n._( + t`You are unable to act on the following workflow approvals: ${itemsUnableToDeny}` + ); + } + + return i18n._(t`Deny`); + }; + + const isDisabled = + selectedItems.length === 0 || selectedItems.some(cannotDeny); + + return ( + <> + {isKebabified ? ( + + {i18n._(t`Deny`)} + + ) : ( + +
+ +
+
+ )} + + ); +} + +WorkflowApprovalListDenyButton.propTypes = { + onDeny: PropTypes.func.isRequired, + selectedItems: PropTypes.arrayOf(WorkflowApproval), +}; + +WorkflowApprovalListDenyButton.defaultProps = { + selectedItems: [], +}; + +export default withI18n()(WorkflowApprovalListDenyButton); diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.jsx new file mode 100644 index 0000000000..7ad7e6beee --- /dev/null +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton'; + +const workflowApproval = { + id: 1, + name: 'Foo', + can_approve_or_deny: true, +}; + +describe('', () => { + 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_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx index dac878c74e..26ed81456c 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx @@ -1,27 +1,26 @@ -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { string, bool, func } from 'prop-types'; import { - DataListAction as _DataListAction, DataListCheck, DataListItem, DataListItemCells, DataListItemRow, + Label, } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import DataListCell from '../../../components/DataListCell'; import { WorkflowApproval } from '../../../types'; import { formatDateString } from '../../../util/dates'; -import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons'; import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus'; -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: repeat(2, 40px); +const StatusCell = styled(DataListCell)` + @media screen and (min-width: 768px) { + display: flex; + justify-content: flex-end; + } `; const JobLabel = styled.b` @@ -35,7 +34,6 @@ function WorkflowApprovalListItem({ detailUrl, i18n, }) { - const [actionTaken, setActionTaken] = useState(false); const labelId = `check-action-${workflowApproval.id}`; const workflowJob = workflowApproval?.summary_fields?.source_workflow_job; @@ -44,23 +42,25 @@ function WorkflowApprovalListItem({ workflowApproval.status === 'pending' && workflowApproval.approval_expiration ) { - return i18n._( - t`Expires on ${formatDateString(workflowApproval.approval_expiration)}` + return ( + ); } if ( workflowApproval.status === 'pending' && !workflowApproval.approval_expiration ) { - return i18n._(t`Never expires`); + return ; } return ; }; - const handleSuccesfulAction = useCallback(() => { - setActionTaken(true); - }, []); - return ( )} , - {getStatus()}, + {getStatus()}, ]} /> - - {workflowApproval.can_approve_or_deny && !actionTaken ? ( - - ) : ( - '' - )} - ); diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx index 9827b71183..a8ec7cf91c 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx @@ -1,16 +1,17 @@ import React from 'react'; - -import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import WorkflowApprovalListItem from './WorkflowApprovalListItem'; -import { WorkflowApprovalsAPI } from '../../../api'; import workflowApproval from '../data.workflowApproval.json'; jest.mock('../../../api/models/WorkflowApprovals'); describe('', () => { - test('action buttons shown to users with ability to approve/deny', () => { - const wrapper = mountWithContexts( + let wrapper; + afterEach(() => { + wrapper.unmount(); + }); + test('should display never expires status', () => { + wrapper = mountWithContexts( ', () => { workflowApproval={workflowApproval} /> ); - expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy(); + expect(wrapper.find('Label[children="Never expires"]').length).toBe(1); }); - - test('action buttons hidden from users without ability to approve/deny', () => { - const wrapper = mountWithContexts( + test('should display timed out status', () => { + wrapper = mountWithContexts( {}} - workflowApproval={{ ...workflowApproval, can_approve_or_deny: false }} + workflowApproval={{ + ...workflowApproval, + status: 'failed', + timed_out: true, + }} /> ); - expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy(); + expect(wrapper.find('Label[children="Timed out"]').length).toBe(1); }); - - test('should hide action buttons after successful action', async () => { - WorkflowApprovalsAPI.approve.mockResolvedValue(); - const wrapper = mountWithContexts( + test('should display canceled status', () => { + wrapper = mountWithContexts( {}} - workflowApproval={workflowApproval} + workflowApproval={{ + ...workflowApproval, + canceled_on: '2020-10-09T19:59:26.974046Z', + status: 'canceled', + }} /> ); - expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy(); - await act(async () => - wrapper.find('Button[aria-label="Approve"]').prop('onClick')() + expect(wrapper.find('Label[children="Canceled"]').length).toBe(1); + }); + test('should display approved status', () => { + wrapper = mountWithContexts( + {}} + workflowApproval={{ + ...workflowApproval, + status: 'successful', + summary_fields: { + ...workflowApproval.summary_fields, + approved_or_denied_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + }, + }} + /> ); - wrapper.update(); - expect(WorkflowApprovalsAPI.approve).toHaveBeenCalled(); - expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy(); - jest.clearAllMocks(); + expect(wrapper.find('Label[children="Approved"]').length).toBe(1); + }); + test('should display denied status', () => { + wrapper = mountWithContexts( + {}} + workflowApproval={{ + ...workflowApproval, + failed: true, + status: 'failed', + summary_fields: { + ...workflowApproval.summary_fields, + approved_or_denied_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + }, + }} + /> + ); + expect(wrapper.find('Label[children="Denied"]').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx deleted file mode 100644 index c187cd39a8..0000000000 --- a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useCallback } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import PropTypes from 'prop-types'; -import { Button, Tooltip } from '@patternfly/react-core'; -import { CheckIcon, CloseIcon } from '@patternfly/react-icons'; -import useRequest, { useDismissableError } from '../../../util/useRequest'; -import AlertModal from '../../../components/AlertModal/AlertModal'; -import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; -import { WorkflowApprovalsAPI } from '../../../api'; - -function WorkflowApprovalActionButtons({ - workflowApproval, - icon, - i18n, - onSuccessfulAction, -}) { - const { - isLoading: approveApprovalLoading, - error: approveApprovalError, - request: approveApprovalNode, - } = useRequest( - useCallback(async () => { - await WorkflowApprovalsAPI.approve(workflowApproval.id); - if (onSuccessfulAction) { - onSuccessfulAction(); - } - }, [onSuccessfulAction, workflowApproval.id]), - {} - ); - - const { - isLoading: denyApprovalLoading, - error: denyApprovalError, - request: denyApprovalNode, - } = useRequest( - useCallback(async () => { - await WorkflowApprovalsAPI.deny(workflowApproval.id); - if (onSuccessfulAction) { - onSuccessfulAction(); - } - }, [onSuccessfulAction, workflowApproval.id]), - {} - ); - - const { - error: approveError, - dismissError: dismissApproveError, - } = useDismissableError(approveApprovalError); - - const { - error: denyError, - dismissError: dismissDenyError, - } = useDismissableError(denyApprovalError); - - return ( - <> - - - - - - - {approveError && ( - - {i18n._(t`Failed to approve this approval node.`)} - - - )} - {denyError && ( - - {i18n._(t`Failed to deny this approval node.`)} - - - )} - - ); -} - -WorkflowApprovalActionButtons.propTypes = { - workflowApproval: PropTypes.shape({}).isRequired, - icon: PropTypes.bool, - onSuccessfulAction: PropTypes.func, -}; - -WorkflowApprovalActionButtons.defaultProps = { - icon: true, - onSuccessfulAction: () => {}, -}; - -export default withI18n()(WorkflowApprovalActionButtons); diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx deleted file mode 100644 index 2cc2242ceb..0000000000 --- a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import { WorkflowApprovalsAPI } from '../../../api'; -import WorkflowApprovalActionButtons from './WorkflowApprovalActionButtons'; -import workflowApproval from '../data.workflowApproval.json'; - -jest.mock('../../../api/models/WorkflowApprovals'); - -describe('', () => { - let wrapper; - afterEach(() => { - wrapper.unmount(); - }); - test('initially renders succesfully with icons', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('CheckIcon').length).toBe(1); - expect(wrapper.find('CloseIcon').length).toBe(1); - expect(wrapper.find('Button[children="Approve"]').length).toBe(0); - expect(wrapper.find('Button[children="Deny"]').length).toBe(0); - }); - test('initially renders succesfully without icons', () => { - wrapper = mountWithContexts( - - ); - expect(wrapper.find('CheckIcon').length).toBe(0); - expect(wrapper.find('CloseIcon').length).toBe(0); - expect(wrapper.find('Button[children="Approve"]').length).toBe(1); - expect(wrapper.find('Button[children="Deny"]').length).toBe(1); - }); - test('approving makes correct call with correct param', async () => { - wrapper = mountWithContexts( - - ); - await act(async () => wrapper.find('CheckIcon').simulate('click')); - expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledWith( - workflowApproval.id - ); - }); - test('denying makes correct call with correct param', async () => { - wrapper = mountWithContexts( - - ); - await act(async () => wrapper.find('CloseIcon').simulate('click')); - expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledWith(workflowApproval.id); - }); - test('approval error shown', async () => { - WorkflowApprovalsAPI.approve.mockRejectedValueOnce( - new Error({ - response: { - config: { - method: 'post', - url: '/api/v2/workflow_approvals/approve', - }, - data: 'An error occurred', - status: 403, - }, - }) - ); - wrapper = mountWithContexts( - - ); - expect(wrapper.find('AlertModal').length).toBe(0); - await act(async () => wrapper.find('CheckIcon').simulate('click')); - wrapper.update(); - expect(wrapper.find('AlertModal').length).toBe(1); - }); - test('denial error shown', async () => { - WorkflowApprovalsAPI.deny.mockRejectedValueOnce( - new Error({ - response: { - config: { - method: 'post', - url: '/api/v2/workflow_approvals/deny', - }, - data: 'An error occurred', - status: 403, - }, - }) - ); - wrapper = mountWithContexts( - - ); - expect(wrapper.find('AlertModal').length).toBe(0); - await act(async () => wrapper.find('CloseIcon').simulate('click')); - wrapper.update(); - expect(wrapper.find('AlertModal').length).toBe(1); - }); -}); diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx index ff6fe14fc6..d1540ca644 100644 --- a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx +++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Label, Tooltip } from '@patternfly/react-core'; -import { InfoCircleIcon } from '@patternfly/react-icons'; +import { CheckIcon, InfoCircleIcon } from '@patternfly/react-icons'; import { WorkflowApproval } from '../../../types'; import { formatDateString } from '../../../util/dates'; @@ -35,7 +35,7 @@ function WorkflowApprovalStatus({ workflowApproval, i18n }) { )} position="top" > -