diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index b3abd8e3bce6..6bc5557f1c4f 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -32,6 +32,7 @@ import Tokens from './models/Tokens';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
+import WorkflowApprovals from './models/WorkflowApprovals';
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
@@ -71,6 +72,7 @@ const TokensAPI = new Tokens();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
+const WorkflowApprovalsAPI = new WorkflowApprovals();
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
@@ -111,6 +113,7 @@ export {
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,
+ WorkflowApprovalsAPI,
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
diff --git a/awx/ui_next/src/api/models/WorkflowApprovals.js b/awx/ui_next/src/api/models/WorkflowApprovals.js
new file mode 100644
index 000000000000..4674d338c56a
--- /dev/null
+++ b/awx/ui_next/src/api/models/WorkflowApprovals.js
@@ -0,0 +1,18 @@
+import Base from '../Base';
+
+class WorkflowApprovals extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/workflow_approvals/';
+ }
+
+ approve(id) {
+ return this.http.post(`${this.baseUrl}${id}/approve/`);
+ }
+
+ deny(id) {
+ return this.http.post(`${this.baseUrl}${id}/deny/`);
+ }
+}
+
+export default WorkflowApprovals;
diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx
index 2dbf67dca4be..9c8958db80e5 100644
--- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx
+++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx
@@ -61,10 +61,6 @@ const ItemToDelete = shape({
}).isRequired,
});
-function cannotDelete(item) {
- return !item.summary_fields.user_capabilities.delete;
-}
-
function ToolbarDeleteButton({
itemsToDelete,
pluralizedItemName,
@@ -72,6 +68,7 @@ function ToolbarDeleteButton({
onDelete,
warningMessage,
i18n,
+ cannotDelete,
}) {
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -100,7 +97,7 @@ function ToolbarDeleteButton({
return (
{errorMessage.length > 0
- ? errorMessage
+ ? `${errorMessage}: ${itemsUnableToDelete}`
: i18n._(
t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}`
)}
@@ -193,12 +190,14 @@ ToolbarDeleteButton.propTypes = {
pluralizedItemName: string,
errorMessage: string,
warningMessage: node,
+ cannotDelete: func,
};
ToolbarDeleteButton.defaultProps = {
pluralizedItemName: 'Items',
errorMessage: '',
warningMessage: null,
+ cannotDelete: item => !item.summary_fields.user_capabilities.delete,
};
export default withI18n()(ToolbarDeleteButton);
diff --git a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap
index 292f506c38e5..fc241959514a 100644
--- a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap
+++ b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap
@@ -2,6 +2,7 @@
exports[`
should render button 1`] = `
,
]}
/>
@@ -164,7 +164,7 @@ function NotificationTemplatesList({ i18n }) {
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
- {i18n._(t`Failed to delete one or more organizations.`)}
+ {i18n._(t`Failed to delete one or more notification template.`)}
>
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx
new file mode 100644
index 000000000000..066a566ec5f4
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx
@@ -0,0 +1,116 @@
+import React, { useEffect, useCallback } from 'react';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+import { Card, PageSection } from '@patternfly/react-core';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+import {
+ Link,
+ Switch,
+ Route,
+ Redirect,
+ useParams,
+ useRouteMatch,
+ useLocation,
+} from 'react-router-dom';
+import useRequest from '../../util/useRequest';
+import RoutedTabs from '../../components/RoutedTabs';
+import ContentError from '../../components/ContentError';
+import { WorkflowApprovalsAPI } from '../../api';
+import WorkflowApprovalDetail from './WorkflowApprovalDetail';
+
+function WorkflowApproval({ setBreadcrumb, i18n }) {
+ const { id: workflowApprovalId } = useParams();
+ const match = useRouteMatch();
+ const location = useLocation();
+ const {
+ result: { workflowApproval },
+ isLoading,
+ error,
+ request: fetchWorkflowApproval,
+ } = useRequest(
+ useCallback(async () => {
+ const detail = await WorkflowApprovalsAPI.readDetail(workflowApprovalId);
+ setBreadcrumb(detail.data);
+ return {
+ workflowApproval: detail.data,
+ };
+ }, [workflowApprovalId, setBreadcrumb]),
+ { workflowApproval: null }
+ );
+
+ useEffect(() => {
+ fetchWorkflowApproval();
+ }, [fetchWorkflowApproval, location.pathname]);
+
+ if (!isLoading && error) {
+ return (
+
+
+
+ {error.response.status === 404 && (
+
+ {i18n._(t`Workflow Approval not found.`)}{' '}
+
+ {i18n._(t`View all Workflow Approvals.`)}
+
+
+ )}
+
+
+
+ );
+ }
+
+ const tabs = [
+ {
+ name: (
+ <>
+
+ {i18n._(t`Back to Workflow Approvals`)}
+ >
+ ),
+ link: `/workflow_approvals`,
+ id: 99,
+ },
+ {
+ name: i18n._(t`Details`),
+ link: `${match.url}/details`,
+ id: 0,
+ },
+ ];
+ return (
+
+
+
+
+
+ {workflowApproval && (
+
+
+
+ )}
+
+ {!isLoading && (
+
+ {match.params.id && (
+
+ {i18n._(t`View Workflow Approval Details`)}
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+export default withI18n()(WorkflowApproval);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.test.jsx
new file mode 100644
index 000000000000..1bec6f09706f
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.test.jsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { WorkflowApprovalsAPI } from '../../api';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../testUtils/enzymeHelpers';
+import mockWorkflowApprovals from './data.workflowApprovals.json';
+import WorkflowApproval from './WorkflowApproval';
+
+jest.mock('../../api');
+
+const mockMe = {
+ is_super_user: true,
+ is_system_auditor: false,
+};
+
+describe('
', () => {
+ test('initially renders succesfully', async () => {
+ WorkflowApprovalsAPI.readDetail.mockResolvedValue({
+ data: mockWorkflowApprovals.results[0],
+ });
+ await act(async () => {
+ mountWithContexts(
+
{}} me={mockMe} />
+ );
+ });
+ });
+
+ test('should show content error when user attempts to navigate to erroneous route', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/workflow_approvals/1/foobar'],
+ });
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} me={mockMe} />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { id: 1 },
+ url: '/workflow_approvals/1/foobar',
+ path: '/workflow_approvals/1/foobar',
+ },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx
new file mode 100644
index 000000000000..eb9e8f66778a
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx
@@ -0,0 +1,261 @@
+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';
+import {
+ Detail,
+ DetailList,
+ UserDateDetail,
+} from '../../../components/DetailList';
+import ErrorDetail from '../../../components/ErrorDetail';
+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: isDeleteLoading,
+ error: deleteApprovalError,
+ } = useRequest(
+ useCallback(async () => {
+ await WorkflowApprovalsAPI.destroy(workflowApprovalId);
+ history.push(`/workflow_approvals`);
+ }, [workflowApprovalId, history])
+ );
+
+ 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;
+
+ const sourceWorkflowJobTemplate =
+ workflowApproval?.summary_fields?.workflow_job_template;
+
+ const isLoading = isDeleteLoading || isApproveLoading || isDenyLoading;
+
+ return (
+
+
+
+
+ {workflowApproval.status === 'pending' && (
+
+ )}
+ {workflowApproval.status !== 'pending' && (
+
+ }
+ dataCy="wa-detail-status"
+ />
+ )}
+ {workflowApproval.summary_fields.approved_or_denied_by && (
+
+ {workflowApproval.summary_fields.approved_or_denied_by.username}
+
+ }
+ dataCy="wa-detail-actor"
+ />
+ )}
+
+
+ {`${sourceWorkflowJob?.id} - ${sourceWorkflowJob?.name}`}
+
+ ) : (
+ i18n._(t`Deleted`)
+ )
+ }
+ dataCy="wa-detail-source-job"
+ />
+
+ {sourceWorkflowJobTemplate?.name}
+
+ )
+ }
+ dataCy="wa-detail-source-workflow"
+ />
+
+
+
+
+
+
+
+ {workflowApproval.can_approve_or_deny && (
+ <>
+
+
+ >
+ )}
+ {workflowApproval.status !== 'pending' &&
+ workflowApproval.summary_fields.user_capabilities &&
+ workflowApproval.summary_fields.user_capabilities.delete && (
+
+ {i18n._(t`Delete`)}
+
+ )}
+
+ {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
new file mode 100644
index 000000000000..4b7c153049e7
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx
@@ -0,0 +1,305 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import { WorkflowApprovalsAPI } from '../../../api';
+import { formatDateString } from '../../../util/dates';
+import WorkflowApprovalDetail from './WorkflowApprovalDetail';
+import mockWorkflowApprovals from '../data.workflowApprovals.json';
+
+const workflowApproval = mockWorkflowApprovals.results[0];
+
+jest.mock('../../../api');
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts(
+
+ );
+ });
+
+ test('should render Details', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ function assertDetail(label, value) {
+ expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
+ }
+ assertDetail('Name', workflowApproval.name);
+ assertDetail('Description', workflowApproval.description);
+ assertDetail('Expires', 'Never');
+ assertDetail(
+ 'Workflow Job',
+ `${workflowApproval.summary_fields.workflow_job.id} - ${workflowApproval.summary_fields.workflow_job.name}`
+ );
+ assertDetail(
+ 'Workflow Job Template',
+ workflowApproval.summary_fields.workflow_job_template.name
+ );
+ const dateDetails = wrapper.find('UserDateDetail');
+ expect(dateDetails).toHaveLength(1);
+ expect(dateDetails.at(0).prop('label')).toEqual('Created');
+ expect(dateDetails.at(0).prop('date')).toEqual(
+ '2020-10-09T17:13:12.067947Z'
+ );
+ expect(dateDetails.at(0).prop('user')).toEqual(
+ workflowApproval.summary_fields.created_by
+ );
+ 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);
+ });
+
+ test('should show expiration date/time', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find(`Detail[label="Expires"] dd`).text()).toBe(
+ `${formatDateString('2020-10-10T17:13:12.067947Z')}`
+ );
+ });
+
+ test('should show finished date/time', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find(`Detail[label="Finished"] dd`).text()).toBe(
+ `${formatDateString('2020-10-10T17:13:12.067947Z')}`
+ );
+ });
+
+ test('should show canceled date/time', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find(`Detail[label="Canceled"] dd`).text()).toBe(
+ `${formatDateString('2020-10-10T17:13:12.067947Z')}`
+ );
+ });
+
+ test('should show explanation', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find(`Detail[label="Explanation"] dd`).text()).toBe(
+ 'Some explanation text'
+ );
+ });
+
+ test('should show status when not pending', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('WorkflowApprovalStatus Label').text()).toBe(
+ 'Approved'
+ );
+ });
+
+ test('should show actor when available', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find(`Detail[label="Actor"] dd`).text()).toBe('Foobar');
+ });
+
+ test('action buttons should be hidden when user cannot approve or deny', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ 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(
+
+ );
+ expect(wrapper.find('DeleteButton').length).toBe(0);
+ });
+
+ test('delete button should be hidden when job is pending', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('DeleteButton').length).toBe(0);
+ });
+
+ test('Error dialog shown for failed deletion', async () => {
+ WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ const wrapper = mountWithContexts(
+
+ );
+ await waitForElement(
+ wrapper,
+ 'WorkflowApprovalDetail Button[aria-label="Delete"]'
+ );
+ await act(async () => {
+ wrapper.find('DeleteButton').invoke('onConfirm')();
+ });
+ 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
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js
new file mode 100644
index 000000000000..c85e6ddd16dc
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js
@@ -0,0 +1,3 @@
+import WorkflowApprovalDetail from './WorkflowApprovalDetail';
+
+export default WorkflowApprovalDetail;
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
new file mode 100644
index 000000000000..bf78d30a8de8
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
@@ -0,0 +1,273 @@
+import React, { useCallback, useEffect } from 'react';
+import { useLocation, useRouteMatch } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Card, PageSection } from '@patternfly/react-core';
+import { WorkflowApprovalsAPI } from '../../../api';
+import PaginatedDataList, {
+ ToolbarDeleteButton,
+} from '../../../components/PaginatedDataList';
+import AlertModal from '../../../components/AlertModal';
+import ErrorDetail from '../../../components/ErrorDetail';
+import DataListToolbar from '../../../components/DataListToolbar';
+import WorkflowApprovalListItem from './WorkflowApprovalListItem';
+import useRequest, {
+ useDeleteItems,
+ useDismissableError,
+} from '../../../util/useRequest';
+import useSelected from '../../../util/useSelected';
+import { getQSConfig, parseQueryString } from '../../../util/qs';
+import useWsWorkflowApprovals from './useWsWorkflowApprovals';
+import WorkflowApprovalListApproveButton from './WorkflowApprovalListApproveButton';
+import WorkflowApprovalListDenyButton from './WorkflowApprovalListDenyButton';
+
+const QS_CONFIG = getQSConfig('workflow_approvals', {
+ page: 1,
+ page_size: 20,
+ order_by: '-started',
+});
+
+function WorkflowApprovalsList({ i18n }) {
+ const location = useLocation();
+ const match = useRouteMatch();
+
+ const {
+ result: { results, count, relatedSearchableKeys, searchableKeys },
+ error: contentError,
+ isLoading: isWorkflowApprovalsLoading,
+ request: fetchWorkflowApprovals,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const [response, actionsResponse] = await Promise.all([
+ WorkflowApprovalsAPI.read(params),
+ WorkflowApprovalsAPI.readOptions(),
+ ]);
+ return {
+ results: response.data.results,
+ count: response.data.count,
+ relatedSearchableKeys: (
+ actionsResponse?.data?.related_search_fields || []
+ ).map(val => val.slice(0, -8)),
+ searchableKeys: Object.keys(
+ actionsResponse.data.actions?.GET || {}
+ ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
+ };
+ }, [location]),
+ {
+ results: [],
+ count: 0,
+ relatedSearchableKeys: [],
+ searchableKeys: [],
+ }
+ );
+
+ useEffect(() => {
+ fetchWorkflowApprovals();
+ }, [fetchWorkflowApprovals]);
+
+ const fetchWorkflowApprovalsById = useCallback(
+ async ids => {
+ const params = { ...parseQueryString(QS_CONFIG, location.search) };
+ params.id__in = ids.join(',');
+ const { data } = await WorkflowApprovalsAPI.read(params);
+ return data.results;
+ },
+ [location.search] // eslint-disable-line react-hooks/exhaustive-deps
+ );
+
+ const workflowApprovals = useWsWorkflowApprovals(
+ results,
+ fetchWorkflowApprovals,
+ fetchWorkflowApprovalsById,
+ QS_CONFIG
+ );
+
+ const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
+ workflowApprovals
+ );
+
+ const {
+ isLoading: isDeleteLoading,
+ deleteItems: deleteWorkflowApprovals,
+ deletionError,
+ clearDeletionError,
+ } = useDeleteItems(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(({ id }) => WorkflowApprovalsAPI.destroy(id))
+ );
+ }, [selected]),
+ {
+ qsConfig: QS_CONFIG,
+ allItemsSelected: isAllSelected,
+ fetchItems: fetchWorkflowApprovals,
+ }
+ );
+
+ const handleDelete = async () => {
+ await deleteWorkflowApprovals();
+ setSelected([]);
+ };
+
+ const {
+ error: approveApprovalError,
+ isLoading: isApproveLoading,
+ request: approveWorkflowApprovals,
+ } = useRequest(
+ useCallback(async () => {
+ return Promise.all(
+ selected.map(({ id }) => WorkflowApprovalsAPI.approve(id))
+ );
+ }, [selected]),
+ {}
+ );
+
+ const handleApprove = async () => {
+ await approveWorkflowApprovals();
+ setSelected([]);
+ };
+
+ 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: actionError,
+ dismissError: dismissActionError,
+ } = useDismissableError(approveApprovalError || denyApprovalError);
+
+ return (
+ <>
+
+
+ (
+
+ setSelected(set ? [...workflowApprovals] : [])
+ }
+ qsConfig={QS_CONFIG}
+ additionalControls={[
+ ,
+ ,
+
+ item.status === 'pending' ||
+ !item.summary_fields.user_capabilities.delete
+ }
+ errorMessage={i18n._(
+ t`These approvals cannot be deleted due to insufficient permissions or a pending job status`
+ )}
+ />,
+ ]}
+ />
+ )}
+ renderItem={workflowApproval => (
+ row.id === workflowApproval.id
+ )}
+ onSelect={() => handleSelect(workflowApproval)}
+ onSuccessfulAction={fetchWorkflowApprovals}
+ />
+ )}
+ />
+
+
+ {deletionError && (
+
+ {i18n._(t`Failed to delete one or more workflow approval.`)}
+
+
+ )}
+ {actionError && (
+
+ {approveApprovalError
+ ? i18n._(t`Failed to approve one or more workflow approval.`)
+ : i18n._(t`Failed to deny one or more workflow approval.`)}
+
+
+ )}
+ >
+ );
+}
+
+export default withI18n()(WorkflowApprovalsList);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.jsx
new file mode 100644
index 000000000000..78a299d1ad01
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.jsx
@@ -0,0 +1,165 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { WorkflowApprovalsAPI } from '../../../api';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import WorkflowApprovalList from './WorkflowApprovalList';
+import mockWorkflowApprovals from '../data.workflowApprovals.json';
+
+jest.mock('../../../api');
+
+describe('', () => {
+ let wrapper;
+ beforeEach(() => {
+ WorkflowApprovalsAPI.read.mockResolvedValue({
+ data: {
+ count: mockWorkflowApprovals.results.length,
+ results: mockWorkflowApprovals.results,
+ },
+ });
+
+ WorkflowApprovalsAPI.readOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should load and render workflow approvals', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+
+ expect(wrapper.find('WorkflowApprovalListItem')).toHaveLength(3);
+ });
+
+ test('should select workflow approval when checked', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .first()
+ .invoke('onSelect')();
+ });
+ wrapper.update();
+
+ expect(
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .first()
+ .prop('isSelected')
+ ).toEqual(true);
+ });
+
+ test('should select all', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
+ });
+ wrapper.update();
+
+ const items = wrapper.find('WorkflowApprovalListItem');
+ expect(items).toHaveLength(3);
+ items.forEach(item => {
+ expect(item.prop('isSelected')).toEqual(true);
+ });
+
+ expect(
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .first()
+ .prop('isSelected')
+ ).toEqual(true);
+ });
+
+ test('should disable delete button', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .at(2)
+ .invoke('onSelect')();
+ });
+ wrapper.update();
+
+ expect(wrapper.find('ToolbarDeleteButton button').prop('disabled')).toEqual(
+ true
+ );
+ });
+
+ test('should call delete api', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .at(1)
+ .invoke('onSelect')();
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
+ });
+
+ expect(WorkflowApprovalsAPI.destroy).toHaveBeenCalledTimes(1);
+ });
+
+ test('should show deletion error', async () => {
+ WorkflowApprovalsAPI.destroy.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/workflow_approvals/221',
+ },
+ data: 'An error occurred',
+ },
+ })
+ );
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ wrapper.update();
+ expect(WorkflowApprovalsAPI.read).toHaveBeenCalledTimes(1);
+ await act(async () => {
+ wrapper
+ .find('WorkflowApprovalListItem')
+ .at(1)
+ .invoke('onSelect')();
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
+ });
+ wrapper.update();
+
+ const modal = wrapper.find('Modal');
+ expect(modal).toHaveLength(1);
+ expect(modal.prop('title')).toEqual('Error!');
+ });
+});
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 000000000000..a61a03c47de9
--- /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 000000000000..949c07ec104e
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListApproveButton.test.jsx
@@ -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_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.jsx
new file mode 100644
index 000000000000..cd07b525470a
--- /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 000000000000..a799ecf208bd
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListDenyButton.test.jsx
@@ -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');
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
new file mode 100644
index 000000000000..085cc33ba2ef
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { string, bool, func } from 'prop-types';
+import {
+ 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 WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
+
+const StatusCell = styled(DataListCell)`
+ @media screen and (min-width: 768px) {
+ display: flex;
+ justify-content: flex-end;
+ }
+`;
+
+const JobLabel = styled.b`
+ margin-right: 24px;
+`;
+
+function WorkflowApprovalListItem({
+ workflowApproval,
+ isSelected,
+ onSelect,
+ detailUrl,
+ i18n,
+}) {
+ 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 (
+
+
+
+
+
+ {workflowApproval.name}
+
+ ,
+
+ <>
+ {i18n._(t`Job`)}
+ {workflowJob && workflowJob?.id ? (
+
+ {`${workflowJob?.id} - ${workflowJob?.name}`}
+
+ ) : (
+ i18n._(t`Deleted`)
+ )}
+ >
+ ,
+
+ {getStatus()}
+ ,
+ ]}
+ />
+
+
+ );
+}
+
+WorkflowApprovalListItem.propTypes = {
+ workflowApproval: WorkflowApproval.isRequired,
+ detailUrl: string.isRequired,
+ isSelected: bool.isRequired,
+ onSelect: func.isRequired,
+};
+
+export default withI18n()(WorkflowApprovalListItem);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
new file mode 100644
index 000000000000..2709ab2fa90f
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import WorkflowApprovalListItem from './WorkflowApprovalListItem';
+import mockWorkflowApprovals from '../data.workflowApprovals.json';
+
+const workflowApproval = mockWorkflowApprovals.results[0];
+
+jest.mock('../../../api/models/WorkflowApprovals');
+
+describe('', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('should display never expires status', () => {
+ wrapper = mountWithContexts(
+ {}}
+ workflowApproval={workflowApproval}
+ />
+ );
+ expect(wrapper.find('Label[children="Never expires"]').length).toBe(1);
+ });
+ test('should display timed out status', () => {
+ wrapper = mountWithContexts(
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ status: 'failed',
+ timed_out: true,
+ }}
+ />
+ );
+ expect(wrapper.find('Label[children="Timed out"]').length).toBe(1);
+ });
+ test('should display canceled status', () => {
+ wrapper = mountWithContexts(
+ {}}
+ workflowApproval={{
+ ...workflowApproval,
+ canceled_on: '2020-10-09T19:59:26.974046Z',
+ status: 'canceled',
+ }}
+ />
+ );
+ 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: '',
+ },
+ },
+ }}
+ />
+ );
+ 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/WorkflowApprovalList/index.js b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/index.js
new file mode 100644
index 000000000000..1ba29467a915
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/index.js
@@ -0,0 +1,4 @@
+import WorkflowApprovalList from './WorkflowApprovalList';
+
+export default WorkflowApprovalList;
+export { default as WorkflowApprovalListItem } from './WorkflowApprovalListItem';
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.js b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.js
new file mode 100644
index 000000000000..977da163beb2
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.js
@@ -0,0 +1,61 @@
+import { useState, useEffect } from 'react';
+import useWebsocket from '../../../util/useWebsocket';
+import useThrottle from '../../../util/useThrottle';
+
+export default function useWsWorkflowApprovals(
+ initialWorkflowApprovals,
+ fetchWorkflowApprovals
+) {
+ const [workflowApprovals, setWorkflowApprovals] = useState(
+ initialWorkflowApprovals
+ );
+ const [reloadEntireList, setReloadEntireList] = useState(false);
+ const throttledListRefresh = useThrottle(reloadEntireList, 1000);
+ const lastMessage = useWebsocket({
+ jobs: ['status_changed'],
+ control: ['limit_reached_1'],
+ });
+
+ useEffect(() => {
+ setWorkflowApprovals(initialWorkflowApprovals);
+ }, [initialWorkflowApprovals]);
+
+ useEffect(
+ function reloadWorkflowApprovalsList() {
+ (async () => {
+ if (!throttledListRefresh) {
+ return;
+ }
+ setReloadEntireList(false);
+ fetchWorkflowApprovals();
+ })();
+ },
+ [throttledListRefresh, fetchWorkflowApprovals]
+ );
+
+ useEffect(
+ function processWsMessage() {
+ if (!(lastMessage?.type === 'workflow_approval')) {
+ return;
+ }
+
+ const index = workflowApprovals.findIndex(
+ p => p.id === lastMessage.unified_job_id
+ );
+
+ if (
+ (index > -1 &&
+ !['new', 'pending', 'waiting', 'running'].includes(
+ lastMessage.status
+ )) ||
+ (index === -1 && lastMessage.status === 'pending')
+ ) {
+ setReloadEntireList(true);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps,
+ [lastMessage]
+ );
+
+ return workflowApprovals;
+}
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.test.jsx
new file mode 100644
index 000000000000..49a7f44acd6d
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.test.jsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import WS from 'jest-websocket-mock';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import useWsWorkflowApprovals from './useWsWorkflowApprovals';
+
+/*
+ Jest mock timers don’t play well with jest-websocket-mock,
+ so we'll stub out throttling to resolve immediately
+*/
+jest.mock('../../../util/useThrottle', () => ({
+ __esModule: true,
+ default: jest.fn(val => val),
+}));
+
+function TestInner() {
+ return ;
+}
+function Test({ workflowApprovals, fetchWorkflowApprovals }) {
+ const updatedWorkflowApprovals = useWsWorkflowApprovals(
+ workflowApprovals,
+ fetchWorkflowApprovals
+ );
+ return ;
+}
+
+describe('useWsWorkflowApprovals hook', () => {
+ let debug;
+ let wrapper;
+ beforeEach(() => {
+ debug = global.console.debug; // eslint-disable-line prefer-destructuring
+ global.console.debug = () => {};
+ });
+
+ afterEach(() => {
+ global.console.debug = debug;
+ WS.clean();
+ });
+
+ test('should return workflow approvals list', () => {
+ const workflowApprovals = [{ id: 1, status: 'successful' }];
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+
+ expect(wrapper.find('TestInner').prop('workflowApprovals')).toEqual(
+ workflowApprovals
+ );
+ });
+
+ test('should establish websocket connection', async () => {
+ global.document.cookie = 'csrftoken=abc123';
+ const mockServer = new WS('wss://localhost/websocket/');
+
+ const workflowApprovals = [{ id: 1, status: 'successful' }];
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+
+ await mockServer.connected;
+ await expect(mockServer).toReceiveMessage(
+ JSON.stringify({
+ xrftoken: 'abc123',
+ groups: {
+ jobs: ['status_changed'],
+ control: ['limit_reached_1'],
+ },
+ })
+ );
+ });
+
+ test('should refetch after new approval job is created', async () => {
+ global.document.cookie = 'csrftoken=abc123';
+ const mockServer = new WS('wss://localhost/websocket/');
+ const workflowApprovals = [{ id: 1, status: 'successful' }];
+ const fetchWorkflowApprovals = jest.fn(() => []);
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+
+ await mockServer.connected;
+ await act(async () => {
+ mockServer.send(
+ JSON.stringify({
+ unified_job_id: 2,
+ type: 'workflow_approval',
+ status: 'pending',
+ })
+ );
+ });
+
+ expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(1);
+ });
+
+ test('should refetch after approval job in current list is updated', async () => {
+ global.document.cookie = 'csrftoken=abc123';
+ const mockServer = new WS('wss://localhost/websocket/');
+ const workflowApprovals = [{ id: 1, status: 'pending' }];
+ const fetchWorkflowApprovals = jest.fn(() => []);
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+
+ await mockServer.connected;
+ await act(async () => {
+ mockServer.send(
+ JSON.stringify({
+ unified_job_id: 1,
+ type: 'workflow_approval',
+ status: 'successful',
+ })
+ );
+ });
+
+ expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(1);
+ });
+
+ test('should not refetch when message is not workflow approval', async () => {
+ global.document.cookie = 'csrftoken=abc123';
+ const mockServer = new WS('wss://localhost/websocket/');
+ const workflowApprovals = [{ id: 1, status: 'successful' }];
+ const fetchWorkflowApprovals = jest.fn(() => []);
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+
+ await mockServer.connected;
+ await act(async () => {
+ mockServer.send(
+ JSON.stringify({
+ unified_job_id: 1,
+ type: 'job',
+ status: 'successful',
+ })
+ );
+ });
+
+ expect(fetchWorkflowApprovals).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx
new file mode 100644
index 000000000000..a8d66ccdbab3
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx
@@ -0,0 +1,42 @@
+import React, { useState, useCallback } from 'react';
+import { Route, Switch, useRouteMatch } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import WorkflowApprovalList from './WorkflowApprovalList';
+import WorkflowApproval from './WorkflowApproval';
+import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
+
+function WorkflowApprovals({ i18n }) {
+ const match = useRouteMatch();
+ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
+ '/workflow_approvals': i18n._(t`Workflow Approvals`),
+ });
+
+ const updateBreadcrumbConfig = useCallback(
+ workflowApproval => {
+ const { id } = workflowApproval;
+ setBreadcrumbConfig({
+ '/workflow_approvals': i18n._(t`Workflow Approvals`),
+ [`/workflow_approvals/${id}`]: workflowApproval.name,
+ [`/workflow_approvals/${id}/details`]: i18n._(t`Details`),
+ });
+ },
+ [i18n]
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default withI18n()(WorkflowApprovals);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx
new file mode 100644
index 000000000000..b5bfdcf2a084
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import WorkflowApprovals from './WorkflowApprovals';
+
+describe('', () => {
+ test('initially renders succesfully', () => {
+ mountWithContexts();
+ });
+
+ test('should display a breadcrumb heading', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/workflow_approvals'],
+ });
+ const match = {
+ path: '/workflow_approvals',
+ url: '/workflow_approvals',
+ isExact: true,
+ };
+
+ const wrapper = mountWithContexts(, {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match,
+ },
+ },
+ },
+ });
+ expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/data.workflowApprovals.json b/awx/ui_next/src/screens/WorkflowApproval/data.workflowApprovals.json
new file mode 100644
index 000000000000..c21ec3df98fd
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/data.workflowApprovals.json
@@ -0,0 +1,227 @@
+{
+ "count": 3,
+ "next": "/api/v2/workflow_approvals/",
+ "previous": null,
+ "results": [
+ {
+ "id": 218,
+ "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": "pending",
+ "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
+ },
+ {
+ "id": 221,
+ "type": "workflow_approval",
+ "url": "/api/v2/workflow_approvals/221/",
+ "related": {
+ "created_by": "/api/v2/users/1/",
+ "unified_job_template": "/api/v2/workflow_approval_templates/10/",
+ "source_workflow_job": "/api/v2/workflow_jobs/220/",
+ "workflow_approval_template": "/api/v2/workflow_approval_templates/10/",
+ "approve": "/api/v2/workflow_approvals/221/approve/",
+ "deny": "/api/v2/workflow_approvals/221/deny/"
+ },
+ "summary_fields": {
+ "workflow_job_template": {
+ "id": 9,
+ "name": "Approval @ 9:15:26 AM",
+ "description": ""
+ },
+ "workflow_job": {
+ "id": 220,
+ "name": "Approval @ 9:15:26 AM",
+ "description": ""
+ },
+ "workflow_approval_template": {
+ "id": 10,
+ "name": "approval copy",
+ "description": "",
+ "timeout": 30
+ },
+ "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": 220,
+ "name": "Approval @ 9:15:26 AM",
+ "description": "",
+ "status": "failed",
+ "failed": true,
+ "elapsed": 89.766
+ }
+ },
+ "created": "2020-10-09T19:58:27.337904Z",
+ "modified": "2020-10-09T19:58:27.338000Z",
+ "name": "approval copy",
+ "description": "",
+ "unified_job_template": 10,
+ "launch_type": "workflow",
+ "status": "failed",
+ "failed": true,
+ "started": "2020-10-09T19:58:27.337904Z",
+ "finished": "2020-10-09T19:59:26.974046Z",
+ "canceled_on": null,
+ "elapsed": 59.636,
+ "job_explanation": "The approval node approval copy (221) has expired after 30 seconds.",
+ "can_approve_or_deny": false,
+ "approval_expiration": null,
+ "timed_out": true
+ },
+ {
+ "id": 6,
+ "type": "workflow_approval",
+ "url": "/api/v2/workflow_approvals/6/",
+ "related": {
+ "created_by": "/api/v2/users/1/",
+ "unified_job_template": "/api/v2/workflow_approval_templates/8/",
+ "source_workflow_job": "/api/v2/workflow_jobs/5/",
+ "workflow_approval_template": "/api/v2/workflow_approval_templates/8/",
+ "approve": "/api/v2/workflow_approvals/6/approve/",
+ "deny": "/api/v2/workflow_approvals/6/deny/",
+ "approved_or_denied_by": "/api/v2/users/1/"
+ },
+ "summary_fields": {
+ "workflow_job_template": {
+ "id": 7,
+ "name": "Approval",
+ "description": ""
+ },
+ "workflow_job": {
+ "id": 5,
+ "name": "Approval",
+ "description": ""
+ },
+ "workflow_approval_template": {
+ "id": 8,
+ "name": "approval",
+ "description": "",
+ "timeout": 0
+ },
+ "unified_job_template": {
+ "id": 8,
+ "name": "approval",
+ "description": "",
+ "unified_job_type": "workflow_approval"
+ },
+ "approved_or_denied_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "created_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "user_capabilities": {
+ "delete": false,
+ "start": false
+ },
+ "source_workflow_job": {
+ "id": 5,
+ "name": "Approval",
+ "description": "",
+ "status": "successful",
+ "failed": false,
+ "elapsed": 168.233
+ }
+ },
+ "created": "2020-10-05T20:14:53.875701Z",
+ "modified": "2020-10-05T20:17:41.211373Z",
+ "name": "approval",
+ "description": "",
+ "unified_job_template": 8,
+ "launch_type": "workflow",
+ "status": "successful",
+ "failed": false,
+ "started": "2020-10-05T20:14:53.875701Z",
+ "finished": "2020-10-05T20:17:41.200738Z",
+ "canceled_on": null,
+ "elapsed": 167.325,
+ "job_explanation": "",
+ "can_approve_or_deny": false,
+ "approval_expiration": null,
+ "timed_out": false
+ }
+ ]
+}
diff --git a/awx/ui_next/src/screens/WorkflowApproval/index.js b/awx/ui_next/src/screens/WorkflowApproval/index.js
new file mode 100644
index 000000000000..0c736e156a91
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowApprovals';
diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx
new file mode 100644
index 000000000000..d1540ca644bd
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { withI18n } from '@lingui/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, i18n }) {
+ if (workflowApproval.status === 'pending') {
+ return workflowApproval.approval_expiration
+ ? i18n._(
+ t`Expires on ${formatDateString(
+ workflowApproval.approval_expiration
+ )}`
+ )
+ : i18n._(t`Never expires`);
+ }
+
+ if (workflowApproval.timed_out) {
+ return ;
+ }
+
+ if (workflowApproval.canceled_on) {
+ return ;
+ }
+
+ if (workflowApproval.status === 'failed' && workflowApproval.failed) {
+ return (
+
+ }>
+ {i18n._(t`Denied`)}
+
+
+ );
+ }
+
+ if (workflowApproval.status === 'successful') {
+ return (
+
+ }>
+ {i18n._(t`Approved`)}
+
+
+ );
+ }
+
+ return null;
+}
+
+WorkflowApprovalStatus.defaultProps = {
+ workflowApproval: WorkflowApproval.isRequired,
+};
+
+export default withI18n()(WorkflowApprovalStatus);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.jsx
new file mode 100644
index 000000000000..41d4965d261c
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.jsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { formatDateString } from '../../../util/dates';
+import WorkflowApprovalStatus from './WorkflowApprovalStatus';
+import mockWorkflowApprovals from '../data.workflowApprovals.json';
+
+const workflowApproval = mockWorkflowApprovals.results[0];
+
+describe('', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ 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_next/src/types.js b/awx/ui_next/src/types.js
index 7f83e3149003..66270820a84c 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -391,3 +391,19 @@ export const NotificationTemplate = shape({
organization: Organization,
}),
});
+
+export const WorkflowApproval = shape({
+ id: number.isRequired,
+ name: string.isRequired,
+ description: string,
+ url: string.isRequired,
+ failed: bool,
+ started: string,
+ finished: string,
+ canceled_on: string,
+ elapsed: number,
+ job_explanation: string,
+ can_approve_or_deny: bool,
+ approval_expiration: string,
+ timed_out: bool,
+});
diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx
index 6b8eeea7e1eb..02251a8e7835 100644
--- a/awx/ui_next/src/util/dates.jsx
+++ b/awx/ui_next/src/util/dates.jsx
@@ -6,10 +6,16 @@ import { getLanguage } from './language';
const prependZeros = value => value.toString().padStart(2, 0);
export function formatDateString(dateString, lang = getLanguage(navigator)) {
+ if (dateString === null) {
+ return null;
+ }
return new Date(dateString).toLocaleString(lang);
}
export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
+ if (dateString === null) {
+ return null;
+ }
return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
}
diff --git a/awx/ui_next/src/util/dates.test.jsx b/awx/ui_next/src/util/dates.test.jsx
index 83e6cb066ab5..5f6162ce8c83 100644
--- a/awx/ui_next/src/util/dates.test.jsx
+++ b/awx/ui_next/src/util/dates.test.jsx
@@ -21,9 +21,14 @@ const i18n = {
describe('formatDateString', () => {
test('it returns the expected value', () => {
const lang = 'en-US';
+ expect(formatDateString(null, lang)).toEqual(null);
expect(formatDateString('', lang)).toEqual('Invalid Date');
expect(formatDateString({}, lang)).toEqual('Invalid Date');
expect(formatDateString(undefined, lang)).toEqual('Invalid Date');
+ expect(formatDateString('foobar', lang)).toEqual('Invalid Date');
+ expect(formatDateString('2018-011-31T01:14:52.969227Z', lang)).toEqual(
+ 'Invalid Date'
+ );
expect(formatDateString('2018-01-31T01:14:52.969227Z', lang)).toEqual(
'1/31/2018, 1:14:52 AM'
);
@@ -33,9 +38,14 @@ describe('formatDateString', () => {
describe('formatDateStringUTC', () => {
test('it returns the expected value', () => {
const lang = 'en-US';
+ expect(formatDateStringUTC(null, lang)).toEqual(null);
expect(formatDateStringUTC('', lang)).toEqual('Invalid Date');
expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date');
expect(formatDateStringUTC(undefined, lang)).toEqual('Invalid Date');
+ expect(formatDateStringUTC('foobar', lang)).toEqual('Invalid Date');
+ expect(formatDateStringUTC('2018-011-31T01:14:52.969227Z', lang)).toEqual(
+ 'Invalid Date'
+ );
expect(formatDateStringUTC('2018-01-31T01:14:52.969227Z', lang)).toEqual(
'1/31/2018, 1:14:52 AM'
);
diff --git a/awx/ui_next/src/util/useThrottle.js b/awx/ui_next/src/util/useThrottle.js
index cfdedfecfcba..242490646140 100644
--- a/awx/ui_next/src/util/useThrottle.js
+++ b/awx/ui_next/src/util/useThrottle.js
@@ -3,12 +3,18 @@ import { useState, useEffect, useRef } from 'react';
export default function useThrottle(value, limit) {
const [throttledValue, setThrottledValue] = useState(value);
const lastRan = useRef(Date.now());
+ const initialValue = useRef(value);
useEffect(() => {
+ if (value !== initialValue.current) {
+ setThrottledValue(value);
+ return () => {};
+ }
+
const handler = setTimeout(() => {
if (Date.now() - lastRan.current >= limit) {
- setThrottledValue(value);
lastRan.current = Date.now();
+ setThrottledValue(value);
}
}, limit - (Date.now() - lastRan.current));