diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index b3abd8e3bc..6bc5557f1c 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 0000000000..4674d338c5
--- /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/routeConfig.js b/awx/ui_next/src/routeConfig.js
index cb936764cb..b35e360fbe 100644
--- a/awx/ui_next/src/routeConfig.js
+++ b/awx/ui_next/src/routeConfig.js
@@ -17,6 +17,7 @@ import Settings from './screens/Setting';
import Teams from './screens/Team';
import Templates from './screens/Template';
import Users from './screens/User';
+import WorkflowApprovals from './screens/WorkflowApproval';
// Ideally, this should just be a regular object that we export, but we
// need the i18n. When lingui3 arrives, we will be able to import i18n
@@ -126,6 +127,11 @@ function getRouteConfig(i18n) {
path: '/applications',
screen: Applications,
},
+ {
+ title: i18n._(t`Workflow Approvals`),
+ path: '/workflow_approvals',
+ screen: WorkflowApprovals,
+ },
],
},
{
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx
index 96bad47c5d..ca55a039c5 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx
@@ -138,7 +138,7 @@ function NotificationTemplatesList({ i18n }) {
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
- pluralizedItemName="Organizations"
+ pluralizedItemName={i18n._(t`Notification Templates`)}
/>,
]}
/>
@@ -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 0000000000..066a566ec5
--- /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 0000000000..17dc835be4
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.test.jsx
@@ -0,0 +1,56 @@
+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 mockDetails from './data.workflowApproval.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: mockDetails });
+ 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 0000000000..c34a84fe02
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx
@@ -0,0 +1,171 @@
+import React, { useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Link, useHistory, useParams } from 'react-router-dom';
+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 WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
+import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
+import { formatDateString, secondsToHHMMSS } from '../../../util/dates';
+import { WorkflowApprovalsAPI } from '../../../api';
+import useRequest, { useDismissableError } from '../../../util/useRequest';
+
+function WorkflowApprovalDetail({ i18n, workflowApproval }) {
+ const { id: workflowApprovalId } = useParams();
+ const history = useHistory();
+ const {
+ request: deleteWorkflowApproval,
+ isLoading,
+ error: deleteError,
+ } = useRequest(
+ useCallback(async () => {
+ await WorkflowApprovalsAPI.destroy(workflowApprovalId);
+ history.push(`/workflow_approvals`);
+ }, [workflowApprovalId, history])
+ );
+
+ const { error, dismissError } = useDismissableError(deleteError);
+
+ const sourceWorkflowJob =
+ workflowApproval?.summary_fields?.source_workflow_job;
+
+ const sourceWorkflowJobTemplate =
+ workflowApproval?.summary_fields?.workflow_job_template;
+
+ const handleSuccesfulAction = useCallback(() => {
+ history.push(`/workflow_approvals/${workflowApprovalId}`);
+ }, [history, workflowApprovalId]);
+
+ return (
+
+
+
+
+ {workflowApproval.status === 'pending' && (
+
+ )}
+ {workflowApproval.status !== 'pending' && (
+
+ }
+ />
+ )}
+ {workflowApproval.summary_fields.approved_or_denied_by && (
+
+ {workflowApproval.summary_fields.approved_or_denied_by.username}
+
+ }
+ />
+ )}
+
+
+ {`${sourceWorkflowJob?.id} - ${sourceWorkflowJob?.name}`}
+
+ )
+ }
+ />
+
+ {sourceWorkflowJobTemplate?.name}
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+ {workflowApproval.can_approve_or_deny && (
+
+ )}
+ {workflowApproval.summary_fields.user_capabilities &&
+ workflowApproval.summary_fields.user_capabilities.delete && (
+
+ {i18n._(t`Delete`)}
+
+ )}
+
+ {error && (
+
+ {i18n._(t`Failed to delete workflow approval.`)}
+
+
+ )}
+
+ );
+}
+
+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 0000000000..f288d5bc60
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx
@@ -0,0 +1,208 @@
+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 workflowApproval from '../data.workflowApproval.json';
+
+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('WorkflowApprovalActionButtons').length).toBe(1);
+ expect(wrapper.find('DeleteButton').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('delete button should be hidden when user cannot delete', () => {
+ 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 0000000000..c85e6ddd16
--- /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 0000000000..ad3e4a01f1
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx
@@ -0,0 +1,187 @@
+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 } from '../../../util/useRequest';
+import useSelected from '../../../util/useSelected';
+import { getQSConfig, parseQueryString } from '../../../util/qs';
+import useWsWorkflowApprovals from './useWsWorkflowApprovals';
+
+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]);
+
+ // TODO: update QS_CONFIG to be safe for deps array
+ 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([]);
+ };
+
+ return (
+ <>
+
+
+ (
+
+ setSelected(set ? [...workflowApprovals] : [])
+ }
+ qsConfig={QS_CONFIG}
+ additionalControls={[
+ ,
+ ]}
+ />
+ )}
+ renderItem={workflowApproval => (
+ row.id === workflowApproval.id
+ )}
+ onSelect={() => handleSelect(workflowApproval)}
+ onSuccessfulAction={fetchWorkflowApprovals}
+ />
+ )}
+ />
+
+
+
+ {i18n._(t`Failed to delete 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 0000000000..ee6391f28b
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.jsx
@@ -0,0 +1,317 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { WorkflowApprovalsAPI } from '../../../api';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import WorkflowApprovalList from './WorkflowApprovalList';
+
+jest.mock('../../../api');
+
+const mockWorkflowApprovals = [
+ {
+ 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,
+ },
+];
+
+describe('', () => {
+ let wrapper;
+ beforeEach(() => {
+ WorkflowApprovalsAPI.read.mockResolvedValue({
+ data: {
+ count: mockWorkflowApprovals.length,
+ results: mockWorkflowApprovals,
+ },
+ });
+
+ 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(2);
+ });
+
+ 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(2);
+ 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(1)
+ .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(0)
+ .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(0)
+ .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/WorkflowApprovalListItem.jsx b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
new file mode 100644
index 0000000000..dac878c74e
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx
@@ -0,0 +1,119 @@
+import React, { useCallback, useState } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { string, bool, func } from 'prop-types';
+import {
+ DataListAction as _DataListAction,
+ DataListCheck,
+ DataListItem,
+ DataListItemCells,
+ DataListItemRow,
+} from '@patternfly/react-core';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import DataListCell from '../../../components/DataListCell';
+import { WorkflowApproval } from '../../../types';
+import { formatDateString } from '../../../util/dates';
+import WorkflowApprovalActionButtons from '../shared/WorkflowApprovalActionButtons';
+import WorkflowApprovalStatus from '../shared/WorkflowApprovalStatus';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(2, 40px);
+`;
+
+const JobLabel = styled.b`
+ margin-right: 24px;
+`;
+
+function WorkflowApprovalListItem({
+ workflowApproval,
+ isSelected,
+ onSelect,
+ detailUrl,
+ i18n,
+}) {
+ const [actionTaken, setActionTaken] = useState(false);
+ const labelId = `check-action-${workflowApproval.id}`;
+ const workflowJob = workflowApproval?.summary_fields?.source_workflow_job;
+
+ const getStatus = () => {
+ if (
+ workflowApproval.status === 'pending' &&
+ workflowApproval.approval_expiration
+ ) {
+ return i18n._(
+ t`Expires on ${formatDateString(workflowApproval.approval_expiration)}`
+ );
+ }
+ if (
+ workflowApproval.status === 'pending' &&
+ !workflowApproval.approval_expiration
+ ) {
+ return i18n._(t`Never expires`);
+ }
+ return ;
+ };
+
+ const handleSuccesfulAction = useCallback(() => {
+ setActionTaken(true);
+ }, []);
+
+ return (
+
+
+
+
+
+ {workflowApproval.name}
+
+ ,
+
+ {workflowJob && (
+ <>
+ {i18n._(t`Job`)}
+
+ {`${workflowJob?.id} - ${workflowJob?.name}`}
+
+ >
+ )}
+ ,
+ {getStatus()},
+ ]}
+ />
+
+ {workflowApproval.can_approve_or_deny && !actionTaken ? (
+
+ ) : (
+ ''
+ )}
+
+
+
+ );
+}
+
+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 0000000000..9827b71183
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import WorkflowApprovalListItem from './WorkflowApprovalListItem';
+import { WorkflowApprovalsAPI } from '../../../api';
+import workflowApproval from '../data.workflowApproval.json';
+
+jest.mock('../../../api/models/WorkflowApprovals');
+
+describe('', () => {
+ test('action buttons shown to users with ability to approve/deny', () => {
+ const wrapper = mountWithContexts(
+ {}}
+ workflowApproval={workflowApproval}
+ />
+ );
+ expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
+ });
+
+ test('action buttons hidden from users without ability to approve/deny', () => {
+ const wrapper = mountWithContexts(
+ {}}
+ workflowApproval={{ ...workflowApproval, can_approve_or_deny: false }}
+ />
+ );
+ expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
+ });
+
+ test('should hide action buttons after successful action', async () => {
+ WorkflowApprovalsAPI.approve.mockResolvedValue();
+ const wrapper = mountWithContexts(
+ {}}
+ workflowApproval={workflowApproval}
+ />
+ );
+ expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeTruthy();
+ await act(async () =>
+ wrapper.find('Button[aria-label="Approve"]').prop('onClick')()
+ );
+ wrapper.update();
+ expect(WorkflowApprovalsAPI.approve).toHaveBeenCalled();
+ expect(wrapper.find('WorkflowApprovalActionButtons').exists()).toBeFalsy();
+ jest.clearAllMocks();
+ });
+});
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 0000000000..1ba29467a9
--- /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 0000000000..977da163be
--- /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 0000000000..49a7f44acd
--- /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 0000000000..a8d66ccdba
--- /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 0000000000..b5bfdcf2a0
--- /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.workflowApproval.json b/awx/ui_next/src/screens/WorkflowApproval/data.workflowApproval.json
new file mode 100644
index 0000000000..29acd9615e
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/data.workflowApproval.json
@@ -0,0 +1,71 @@
+{
+ "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
+}
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 0000000000..0c736e156a
--- /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/WorkflowApprovalActionButtons.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx
new file mode 100644
index 0000000000..c187cd39a8
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx
@@ -0,0 +1,115 @@
+import React, { useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import PropTypes from 'prop-types';
+import { Button, Tooltip } from '@patternfly/react-core';
+import { CheckIcon, CloseIcon } from '@patternfly/react-icons';
+import useRequest, { useDismissableError } from '../../../util/useRequest';
+import AlertModal from '../../../components/AlertModal/AlertModal';
+import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
+import { WorkflowApprovalsAPI } from '../../../api';
+
+function WorkflowApprovalActionButtons({
+ workflowApproval,
+ icon,
+ i18n,
+ onSuccessfulAction,
+}) {
+ const {
+ isLoading: approveApprovalLoading,
+ error: approveApprovalError,
+ request: approveApprovalNode,
+ } = useRequest(
+ useCallback(async () => {
+ await WorkflowApprovalsAPI.approve(workflowApproval.id);
+ if (onSuccessfulAction) {
+ onSuccessfulAction();
+ }
+ }, [onSuccessfulAction, workflowApproval.id]),
+ {}
+ );
+
+ const {
+ isLoading: denyApprovalLoading,
+ error: denyApprovalError,
+ request: denyApprovalNode,
+ } = useRequest(
+ useCallback(async () => {
+ await WorkflowApprovalsAPI.deny(workflowApproval.id);
+ if (onSuccessfulAction) {
+ onSuccessfulAction();
+ }
+ }, [onSuccessfulAction, workflowApproval.id]),
+ {}
+ );
+
+ const {
+ error: approveError,
+ dismissError: dismissApproveError,
+ } = useDismissableError(approveApprovalError);
+
+ const {
+ error: denyError,
+ dismissError: dismissDenyError,
+ } = useDismissableError(denyApprovalError);
+
+ return (
+ <>
+
+
+
+
+
+
+ {approveError && (
+
+ {i18n._(t`Failed to approve this approval node.`)}
+
+
+ )}
+ {denyError && (
+
+ {i18n._(t`Failed to deny this approval node.`)}
+
+
+ )}
+ >
+ );
+}
+
+WorkflowApprovalActionButtons.propTypes = {
+ workflowApproval: PropTypes.shape({}).isRequired,
+ icon: PropTypes.bool,
+ onSuccessfulAction: PropTypes.func,
+};
+
+WorkflowApprovalActionButtons.defaultProps = {
+ icon: true,
+ onSuccessfulAction: () => {},
+};
+
+export default withI18n()(WorkflowApprovalActionButtons);
diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx
new file mode 100644
index 0000000000..2cc2242ceb
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { WorkflowApprovalsAPI } from '../../../api';
+import WorkflowApprovalActionButtons from './WorkflowApprovalActionButtons';
+import workflowApproval from '../data.workflowApproval.json';
+
+jest.mock('../../../api/models/WorkflowApprovals');
+
+describe('', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('initially renders succesfully with icons', () => {
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('CheckIcon').length).toBe(1);
+ expect(wrapper.find('CloseIcon').length).toBe(1);
+ expect(wrapper.find('Button[children="Approve"]').length).toBe(0);
+ expect(wrapper.find('Button[children="Deny"]').length).toBe(0);
+ });
+ test('initially renders succesfully without icons', () => {
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('CheckIcon').length).toBe(0);
+ expect(wrapper.find('CloseIcon').length).toBe(0);
+ expect(wrapper.find('Button[children="Approve"]').length).toBe(1);
+ expect(wrapper.find('Button[children="Deny"]').length).toBe(1);
+ });
+ test('approving makes correct call with correct param', async () => {
+ wrapper = mountWithContexts(
+
+ );
+ await act(async () => wrapper.find('CheckIcon').simulate('click'));
+ expect(WorkflowApprovalsAPI.approve).toHaveBeenCalledWith(
+ workflowApproval.id
+ );
+ });
+ test('denying makes correct call with correct param', async () => {
+ wrapper = mountWithContexts(
+
+ );
+ await act(async () => wrapper.find('CloseIcon').simulate('click'));
+ expect(WorkflowApprovalsAPI.deny).toHaveBeenCalledWith(workflowApproval.id);
+ });
+ test('approval error shown', async () => {
+ WorkflowApprovalsAPI.approve.mockRejectedValueOnce(
+ new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/workflow_approvals/approve',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ await act(async () => wrapper.find('CheckIcon').simulate('click'));
+ wrapper.update();
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ });
+ test('denial error shown', async () => {
+ WorkflowApprovalsAPI.deny.mockRejectedValueOnce(
+ new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/workflow_approvals/deny',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ await act(async () => wrapper.find('CloseIcon').simulate('click'));
+ wrapper.update();
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx
new file mode 100644
index 0000000000..ff6fe14fc6
--- /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 { 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 0000000000..3f8bd066cf
--- /dev/null
+++ b/awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { formatDateString } from '../../../util/dates';
+import WorkflowApprovalStatus from './WorkflowApprovalStatus';
+import workflowApproval from '../data.workflowApproval.json';
+
+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 7f83e31490..66270820a8 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 6b8eeea7e1..02251a8e78 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 83e6cb066a..5f6162ce8c 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 cfdedfecfc..2424906461 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));