diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js
index 52edf9e865..dcfef81448 100644
--- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js
@@ -12,11 +12,16 @@ import PaginatedTable, {
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import DataListToolbar from 'components/DataListToolbar';
-import useRequest, { useDeleteItems } from 'hooks/useRequest';
+import useRequest, {
+ useDeleteItems,
+ useDismissableError,
+} from 'hooks/useRequest';
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';
const QS_CONFIG = getQSConfig('workflow_approvals', {
page: 1,
@@ -104,7 +109,50 @@ function WorkflowApprovalsList() {
clearSelected();
};
- const isLoading = isWorkflowApprovalsLoading || isDeleteLoading;
+ 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 () => {
+ await denyWorkflowApprovals();
+ clearSelected();
+ };
+
+ const { error: actionError, dismissError: dismissActionError } =
+ useDismissableError(approveApprovalError || denyApprovalError);
+
+ const isLoading =
+ isWorkflowApprovalsLoading ||
+ isDeleteLoading ||
+ isApproveLoading ||
+ isDenyLoading;
return (
<>
@@ -138,6 +186,16 @@ function WorkflowApprovalsList() {
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
additionalControls={[
+ ,
+ ,
)}
+ {actionError && (
+
+ {approveApprovalError
+ ? t`Failed to approve one or more workflow approval.`
+ : t`Failed to deny one or more workflow approval.`}
+
+
+ )}
>
);
}
diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js
new file mode 100644
index 0000000000..e9c32927ac
--- /dev/null
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.js
@@ -0,0 +1,76 @@
+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 (
+ /* eslint-disable-next-line react/jsx-no-useless-fragment */
+ <>
+ {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
new file mode 100644
index 0000000000..949c07ec10
--- /dev/null
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.js
@@ -0,0 +1,56 @@
+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
new file mode 100644
index 0000000000..a3cff0c231
--- /dev/null
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.js
@@ -0,0 +1,76 @@
+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 (
+ /* eslint-disable-next-line react/jsx-no-useless-fragment */
+ <>
+ {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
new file mode 100644
index 0000000000..a799ecf208
--- /dev/null
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.js
@@ -0,0 +1,53 @@
+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');
+ });
+});