diff --git a/awx/ui/src/components/AppContainer/NavExpandableGroup.js b/awx/ui/src/components/AppContainer/NavExpandableGroup.js index 3e27257e81..a5c1621708 100644 --- a/awx/ui/src/components/AppContainer/NavExpandableGroup.js +++ b/awx/ui/src/components/AppContainer/NavExpandableGroup.js @@ -20,12 +20,7 @@ function NavExpandableGroup(props) { if (routes.length === 1 && groupId === 'settings') { const [{ path }] = routes; return ( - + {groupTitle} ); @@ -40,12 +35,7 @@ function NavExpandableGroup(props) { title={groupTitle} > {routes.map(({ path, title }) => ( - + {title} ))} diff --git a/awx/ui/src/components/JobCancelButton/JobCancelButton.js b/awx/ui/src/components/JobCancelButton/JobCancelButton.js index b766a3b434..30bea62eab 100644 --- a/awx/ui/src/components/JobCancelButton/JobCancelButton.js +++ b/awx/ui/src/components/JobCancelButton/JobCancelButton.js @@ -15,6 +15,9 @@ function JobCancelButton({ buttonText, style = {}, job = {}, + isDisabled, + tooltip, + cancelationMessage, }) { const [isOpen, setIsOpen] = useState(false); const { error: cancelError, request: cancelJob } = useRequest( @@ -28,33 +31,40 @@ function JobCancelButton({ useDismissableError(cancelError); const isAlreadyCancelled = cancelError?.response?.status === 405; - + const renderTooltip = () => { + if (tooltip) { + return tooltip; + } + return isAlreadyCancelled ? null : title; + }; return ( <> - - {showIconButton ? ( - - ) : ( - - )} + +
+ {showIconButton ? ( + + ) : ( + + )} +
{isOpen && ( , ]} > - {t`Are you sure you want to cancel this job?`} + {cancelationMessage ?? t`Are you sure you want to cancel this job?`} )} {error && !isAlreadyCancelled && ( diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js index e8eadad293..7a68fd9986 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { t } from '@lingui/macro'; import { Link, useHistory, useParams } from 'react-router-dom'; @@ -26,13 +26,14 @@ import { import useRequest, { useDismissableError } from 'hooks/useRequest'; import { WorkflowApproval } from 'types'; import StatusLabel from 'components/StatusLabel'; +import JobCancelButton from 'components/JobCancelButton'; +import WorkflowApprovalButton from '../shared/WorkflowApprovalButton'; +import WorkflowDenyButton from '../shared/WorkflowDenyButton'; import { getDetailPendingLabel, getStatus, } from '../shared/WorkflowApprovalUtils'; -import WorkflowApprovalControls from '../shared/WorkflowApprovalControls'; - const Divider = styled(PFDivider)` margin-top: var(--pf-global--spacer--lg); margin-bottom: var(--pf-global--spacer--lg); @@ -49,7 +50,6 @@ const WFDetailList = styled(DetailList)` function WorkflowApprovalDetail({ workflowApproval }) { const { id: workflowApprovalId } = useParams(); - const [isKebabOpen, setIsKebabModalOpen] = useState(false); const history = useHistory(); const { request: deleteWorkflowApproval, @@ -65,50 +65,6 @@ function WorkflowApprovalDetail({ workflowApproval }) { 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 { - 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 workflowJobTemplateId = workflowApproval.summary_fields.workflow_job_template.id; @@ -146,26 +102,13 @@ function WorkflowApprovalDetail({ workflowApproval }) { fetchWorkflowJob(); }, [fetchWorkflowJob]); - 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 = - isApproveLoading || - isCancelLoading || - isDeleteLoading || - isDenyLoading || - isLoadingWorkflowJob; + const isLoading = isDeleteLoading || isLoadingWorkflowJob; if (isLoadingWorkflowJob) { return ; @@ -342,16 +285,23 @@ function WorkflowApprovalDetail({ workflowApproval }) { {workflowApproval.status === 'pending' && workflowApproval.can_approve_or_deny && ( - - setIsKebabModalOpen(isOpen) - } - isKebabOpen={isKebabOpen} - /> + <> + + + + )} {workflowApproval.status !== 'pending' && workflowApproval.summary_fields?.user_capabilities?.delete && ( @@ -376,39 +326,6 @@ function WorkflowApprovalDetail({ workflowApproval }) { )} - {approveError && ( - - {t`Failed to approve workflow approval.`} - - - )} - {cancelError && ( - - {t`Failed to approve workflow approval.`} - - - )} - {denyError && ( - - {t`Failed to deny workflow approval.`} - - - )} ); } diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js index 2889599d45..6b46536a38 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js @@ -326,7 +326,6 @@ describe('', () => { expect(wrapper.find('VariablesDetail').prop('value')).toEqual( '{"foo": "bar", "baz": "qux", "first_one": 10}' ); - expect(wrapper.find('WorkflowApprovalControls').length).toBe(1); }); test('should show expiration date/time', async () => { @@ -521,7 +520,10 @@ describe('', () => { }); waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0); await act(async () => { - wrapper.find('DropdownToggleAction').invoke('onClick')(); + wrapper + .find('Button[ouiaId="workflow-approve-button"]') + .at(0) + .invoke('onClick')(); }); expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledTimes(1); await waitForElement( @@ -550,16 +552,8 @@ describe('', () => { ); }); waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0); - await act(async () => wrapper.find('Toggle').prop('onToggle')(true)); - wrapper.update(); - await waitForElement( - wrapper, - 'WorkflowApprovalDetail DropdownItem[ouiaId="workflow-deny-button"]' - ); await act(async () => { - wrapper - .find('DropdownItem[ouiaId="workflow-deny-button"]') - .invoke('onClick')(); + wrapper.find('Button[ouiaId="workflow-deny-button"]').invoke('onClick')(); }); expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledTimes(1); await waitForElement( @@ -577,93 +571,6 @@ describe('', () => { ); }); - test('delete button should be hidden when user cannot delete', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0); - expect(wrapper.find('DeleteButton').length).toBe(0); - }); - - test('delete button should be hidden when job is pending and approve, action buttons should render', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0); - - expect(wrapper.find('DeleteButton').length).toBe(0); - expect(wrapper.find('WorkflowApprovalControls').length).toBe(1); - await act(async () => wrapper.find('Toggle').prop('onToggle')(true)); - wrapper.update(); - - const denyItem = wrapper.find( - 'DropdownItem[ouiaId="workflow-deny-button"]' - ); - const cancelItem = wrapper.find( - 'DropdownItem[ouiaId="workflow-cancel-button"]' - ); - expect(denyItem).toHaveLength(1); - expect(denyItem.prop('description')).toBe( - 'This will continue the workflow along failure and always paths.' - ); - expect(cancelItem).toHaveLength(1); - expect(cancelItem.prop('description')).toBe( - 'This will cancel the workflow and no subsequent nodes will execute.' - ); - expect( - wrapper.find('DropdownItem[ouiaId="workflow-delete-button"]') - ).toHaveLength(0); - }); - - test('Delete button is visible and approve action is not', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0); - 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 () => { WorkflowApprovalsAPI.destroy.mockImplementationOnce(() => Promise.reject(new Error()) diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js index c85e6ddd16..97937a42ed 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js @@ -1,3 +1 @@ -import WorkflowApprovalDetail from './WorkflowApprovalDetail'; - -export default WorkflowApprovalDetail; +export { default } from './WorkflowApprovalDetail'; diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js index cc4e15af0b..52edf9e865 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, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useLocation, useRouteMatch } from 'react-router-dom'; import { t, Plural } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; -import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api'; +import { WorkflowApprovalsAPI } from 'api'; import PaginatedTable, { HeaderRow, HeaderCell, @@ -12,15 +12,11 @@ import PaginatedTable, { import AlertModal from 'components/AlertModal'; import ErrorDetail from 'components/ErrorDetail'; import DataListToolbar from 'components/DataListToolbar'; -import useRequest, { - useDeleteItems, - useDismissableError, -} from 'hooks/useRequest'; +import useRequest, { useDeleteItems } from 'hooks/useRequest'; import useSelected from 'hooks/useSelected'; import { getQSConfig, parseQueryString } from 'util/qs'; import WorkflowApprovalListItem from './WorkflowApprovalListItem'; import useWsWorkflowApprovals from './useWsWorkflowApprovals'; -import WorkflowApprovalControls from '../shared/WorkflowApprovalControls'; const QS_CONFIG = getQSConfig('workflow_approvals', { page: 1, @@ -31,7 +27,6 @@ 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 }, @@ -109,79 +104,7 @@ function WorkflowApprovalsList() { clearSelected(); }; - const { - error: approveApprovalError, - isLoading: isApproveLoading, - request: approveWorkflowApprovals, - } = useRequest( - useCallback( - async () => - Promise.all(selected.map(({ id }) => WorkflowApprovalsAPI.approve(id))), - [selected] - ), - {} - ); - - const handleApprove = async () => { - await approveWorkflowApprovals(); - clearSelected(); - }; - - const { - error: denyApprovalError, - isLoading: isDenyLoading, - request: denyWorkflowApprovals, - } = useRequest( - useCallback( - async () => - Promise.all(selected.map(({ id }) => WorkflowApprovalsAPI.deny(id))), - [selected] - ), - {} - ); - - const handleDeny = async () => { - setIsKebabModalOpen(false); - await denyWorkflowApprovals(); - clearSelected(); - }; - - 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; + const isLoading = isWorkflowApprovalsLoading || isDeleteLoading; return ( <> @@ -215,17 +138,6 @@ function WorkflowApprovalsList() { onSelectAll={selectAll} qsConfig={QS_CONFIG} additionalControls={[ - - setIsKebabModalOpen(isOpen) - } - isKebabOpen={isKebabOpen} - />, {t`Workflow Job`} {t`Started`} {t`Status`} + {t`Actions`} } renderRow={(workflowApproval, index) => ( @@ -263,7 +176,6 @@ function WorkflowApprovalsList() { (row) => row.id === workflowApproval.id )} onSelect={() => handleSelect(workflowApproval)} - onSuccessfulAction={fetchWorkflowApprovals} rowIndex={index} /> )} @@ -281,39 +193,6 @@ function WorkflowApprovalsList() { )} - {approveError && ( - - {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 840381236f..c7d2726ac6 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.js @@ -79,27 +79,7 @@ describe('', () => { ).toEqual(true); }); - 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 () => { + test('Delete button is active', async () => { await act(async () => { wrapper = mountWithContexts(); }); @@ -111,33 +91,6 @@ describe('', () => { 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(3).invoke('onSelect')(); - }); - wrapper.update(); - expect( - wrapper - .find('WorkflowApprovalControls') - .find('DropdownToggle') - .prop('isDisabled') - ).toEqual(true); - expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( - false - ); }); test('should call delete api', async () => { diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js index a5aebbf578..b72f5fb446 100644 --- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js +++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.js @@ -6,11 +6,15 @@ import { Link } from 'react-router-dom'; import { WorkflowApproval } from 'types'; import { formatDateString } from 'util/dates'; import StatusLabel from 'components/StatusLabel'; +import JobCancelButton from 'components/JobCancelButton'; +import { ActionItem, ActionsTd } from 'components/PaginatedTable'; import { getPendingLabel, getStatus, getTooltip, } from '../shared/WorkflowApprovalUtils'; +import WorkflowApprovalButton from '../shared/WorkflowApprovalButton'; +import WorkflowDenyButton from '../shared/WorkflowDenyButton'; function WorkflowApprovalListItem({ workflowApproval, @@ -19,8 +23,13 @@ function WorkflowApprovalListItem({ detailUrl, rowIndex, }) { + const hasBeenActedOn = + workflowApproval.status === 'successful' || + workflowApproval.status === 'failed' || + workflowApproval.status === 'canceled'; const labelId = `check-action-${workflowApproval.id}`; const workflowJob = workflowApproval?.summary_fields?.source_workflow_job; + const status = getStatus(workflowApproval); return ( )} + + + + + + + + + + + ); } diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.js new file mode 100644 index 0000000000..0b60bdb27a --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.js @@ -0,0 +1,57 @@ +import React, { useCallback } from 'react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { OutlinedThumbsUpIcon } from '@patternfly/react-icons'; +import { WorkflowApprovalsAPI } from 'api'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; + +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import { getStatus } from './WorkflowApprovalUtils'; + +function WorkflowApprovalButton({ isDetailView, workflowApproval }) { + const { id } = workflowApproval; + const hasBeenActedOn = workflowApproval.status === 'successful'; + const { error: approveApprovalError, request: approveWorkflowApprovals } = + useRequest( + useCallback(async () => WorkflowApprovalsAPI.approve(id), [id]), + {} + ); + + const handleApprove = async () => { + await approveWorkflowApprovals(); + }; + + const { error: approveError, dismissError: dismissApproveError } = + useDismissableError(approveApprovalError); + + return ( + <> + + {approveError && ( + + {t`Failed to approve ${workflowApproval.name}.`} + + + )} + + ); +} +export default WorkflowApprovalButton; diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.test.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.test.js new file mode 100644 index 0000000000..f1f7989ffc --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalButton.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + shallowWithContexts, +} from '../../../../testUtils/enzymeHelpers'; +import { WorkflowApprovalsAPI } from 'api'; +import WorkflowApprovalButton from './WorkflowApprovalButton'; +import mockData from '../data.workflowApprovals.json'; + +jest.mock('api'); + +describe(' shallow mount', () => { + let wrapper; + + let mockApprovalList = mockData.results; + + test('initially render successfully', () => { + wrapper = shallowWithContexts( + + ); + + expect(wrapper.find('WorkflowApprovalButton')).toHaveLength(1); + wrapper + .find('Button') + .forEach((button) => expect(button.prop('isDisabled')).toBe(false)); + }); +}); + +describe(', full mount', () => { + let wrapper; + let mockApprovalList = mockData.results; + const approveButton = 'Button[ouiaId="workflow-approve-button"]'; + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should be disabled', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find(approveButton)).toHaveLength(1); + expect(wrapper.find(approveButton).prop('isDisabled')).toBe(true); + }); + test('should handle approve', async () => { + act(() => { + wrapper = mountWithContexts( + + ); + }); + await act(() => wrapper.find(approveButton).prop('onClick')()); + expect(WorkflowApprovalsAPI.approve).toBeCalledWith(218); + }); +}); diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js deleted file mode 100644 index 90ae428182..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.js +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 8a66c041fd..0000000000 --- a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowApprovalControls.test.js +++ /dev/null @@ -1,94 +0,0 @@ -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/WorkflowDenyButton.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowDenyButton.js new file mode 100644 index 0000000000..b0546f6726 --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowDenyButton.js @@ -0,0 +1,58 @@ +import React, { useCallback } from 'react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import { OutlinedThumbsDownIcon } from '@patternfly/react-icons'; +import { WorkflowApprovalsAPI } from 'api'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; + +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import { getStatus } from './WorkflowApprovalUtils'; + +function WorkflowDenyButton({ isDetailView, workflowApproval }) { + const hasBeenActedOn = workflowApproval.status === 'failed'; + const { id } = workflowApproval; + + const { error: denyApprovalError, request: denyWorkflowApprovals } = + useRequest( + useCallback(async () => WorkflowApprovalsAPI.deny(id), [id]), + {} + ); + + const handleDeny = async () => { + await denyWorkflowApprovals(); + }; + + const { error: denyError, dismissError: dismissDenyError } = + useDismissableError(denyApprovalError); + + return ( + <> + + {denyError && ( + + {t`Failed to deny ${workflowApproval.name}.`} + + + )} + + ); +} +export default WorkflowDenyButton; diff --git a/awx/ui/src/screens/WorkflowApproval/shared/WorkflowDenyButton.test.js b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowDenyButton.test.js new file mode 100644 index 0000000000..ebfef860c4 --- /dev/null +++ b/awx/ui/src/screens/WorkflowApproval/shared/WorkflowDenyButton.test.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + shallowWithContexts, +} from '../../../../testUtils/enzymeHelpers'; +import { WorkflowApprovalsAPI } from 'api'; +import WorkflowDenyButton from './WorkflowDenyButton'; +import mockData from '../data.workflowApprovals.json'; + +jest.mock('api'); + +describe(' shallow mount', () => { + let wrapper; + + let mockApprovalList = mockData.results; + + test('initially render successfully', () => { + wrapper = shallowWithContexts( + + ); + + expect(wrapper.find('WorkflowDenyButton')).toHaveLength(1); + wrapper + .find('Button') + .forEach((button) => expect(button.prop('isDisabled')).toBe(false)); + }); +}); + +describe(', full mount', () => { + let wrapper; + let mockApprovalList = mockData.results; + const denyButton = 'Button[ouiaId="workflow-deny-button"]'; + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should be disabled', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find(denyButton)).toHaveLength(1); + expect(wrapper.find(denyButton).prop('isDisabled')).toBe(true); + }); + + test('should handle deny', async () => { + act(() => { + wrapper = mountWithContexts( + + ); + }); + await act(() => wrapper.find(denyButton).prop('onClick')()); + expect(WorkflowApprovalsAPI.deny).toBeCalledWith(218); + }); + + test('Should handle deny error', async () => { + WorkflowApprovalsAPI.deny.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/workflow', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + act(() => { + wrapper = mountWithContexts( + + ); + }); + await act(() => wrapper.find(denyButton).prop('onClick')()); + wrapper.update(); + expect(wrapper.find('ErrorDetail')).toHaveLength(1); + }); +});