diff --git a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js
index b9bb928a73..03e6faf8bd 100644
--- a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js
+++ b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js
@@ -368,7 +368,7 @@ describe('', () => {
response: {
config: {
method: 'get',
- url: '/api/v2/credentals',
+ url: '/api/v2/credentials',
},
data: 'An error occurred',
status: 403,
diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApproval.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApproval.js
index 351e380fa2..92bdf9d633 100644
--- a/awx/ui/src/screens/WorkflowApproval/WorkflowApproval.js
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApproval.js
@@ -90,10 +90,7 @@ function WorkflowApproval({ setBreadcrumb }) {
/>
{workflowApproval && (
-
+
)}
diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js
index 445bd99dd0..ee8957c70a 100644
--- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.js
@@ -1,14 +1,28 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { Link, useHistory, useParams } from 'react-router-dom';
+import styled from 'styled-components';
+import {
+ Divider as PFDivider,
+ Title as PFTitle,
+ Chip,
+} from '@patternfly/react-core';
import AlertModal from 'components/AlertModal';
-import { CardBody, CardActionsRow } from 'components/Card';
+import ChipGroup from 'components/ChipGroup';
+import ContentError from 'components/ContentError';
+import ContentLoading from 'components/ContentLoading';
import DeleteButton from 'components/DeleteButton';
-import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
import ErrorDetail from 'components/ErrorDetail';
+import { CardBody, CardActionsRow } from 'components/Card';
+import { Detail, DetailList, UserDateDetail } from 'components/DetailList';
+import { VariablesDetail } from 'components/CodeEditor';
import { formatDateString, secondsToHHMMSS } from 'util/dates';
-import { WorkflowApprovalsAPI, WorkflowJobsAPI } from 'api';
+import {
+ WorkflowApprovalsAPI,
+ WorkflowJobTemplatesAPI,
+ WorkflowJobsAPI,
+} from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import { WorkflowApproval } from 'types';
import StatusLabel from 'components/StatusLabel';
@@ -16,8 +30,23 @@ import {
getDetailPendingLabel,
getStatus,
} from '../shared/WorkflowApprovalUtils';
+
import WorkflowApprovalControls from '../shared/WorkflowApprovalControls';
+const Divider = styled(PFDivider)`
+ margin-top: var(--pf-global--spacer--lg);
+ margin-bottom: var(--pf-global--spacer--lg);
+`;
+
+const Title = styled(PFTitle)`
+ margin-top: var(--pf-global--spacer--xl);
+ --pf-c-title--m-md--FontWeight: 700;
+`;
+
+const WFDetailList = styled(DetailList)`
+ padding: 0px var(--pf-global--spacer--lg);
+`;
+
function WorkflowApprovalDetail({ workflowApproval }) {
const { id: workflowApprovalId } = useParams();
const [isKebabOpen, setIsKebabModalOpen] = useState(false);
@@ -80,6 +109,43 @@ function WorkflowApprovalDetail({ workflowApproval }) {
{}
);
+ const workflowJobTemplateId =
+ workflowApproval.summary_fields.workflow_job_template.id;
+
+ const {
+ error: fetchWorkflowJobError,
+ isLoading: isLoadingWorkflowJob,
+ request: fetchWorkflowJob,
+ result: workflowJob,
+ } = useRequest(
+ useCallback(async () => {
+ if (!workflowJobTemplateId) {
+ return {};
+ }
+ const { data: workflowJobTemplate } =
+ await WorkflowJobTemplatesAPI.readDetail(workflowJobTemplateId);
+
+ let jobId = null;
+
+ if (workflowJobTemplate.summary_fields?.current_job) {
+ jobId = workflowJobTemplate.summary_fields.current_job.id;
+ } else if (workflowJobTemplate.summary_fields?.last_job) {
+ jobId = workflowJobTemplate.summary_fields.last_job.id;
+ }
+ const { data } = await WorkflowJobsAPI.readDetail(jobId);
+
+ return data;
+ }, [workflowJobTemplateId]),
+ {
+ workflowJob: null,
+ isLoading: true,
+ }
+ );
+
+ useEffect(() => {
+ fetchWorkflowJob();
+ }, [fetchWorkflowJob]);
+
const handleCancel = async () => {
setIsKebabModalOpen(false);
await cancelWorkflowApprovals();
@@ -95,7 +161,18 @@ function WorkflowApprovalDetail({ workflowApproval }) {
workflowApproval?.summary_fields?.workflow_job_template;
const isLoading =
- isDeleteLoading || isApproveLoading || isDenyLoading || isCancelLoading;
+ isApproveLoading ||
+ isCancelLoading ||
+ isDeleteLoading ||
+ isDenyLoading ||
+ isLoadingWorkflowJob;
+
+ if (isLoadingWorkflowJob) {
+ return ;
+ }
+ if (fetchWorkflowJobError) {
+ return ;
+ }
return (
@@ -146,19 +223,6 @@ function WorkflowApprovalDetail({ workflowApproval }) {
value={workflowApproval.job_explanation}
dataCy="wa-detail-explanation"
/>
-
- {`${sourceWorkflowJob?.id} - ${sourceWorkflowJob?.name}`}
-
- ) : (
- t`Deleted`
- )
- }
- dataCy="wa-detail-source-job"
- />
+ {t`Workflow job details`}
+
+
+
+ {`${sourceWorkflowJob?.id} - ${sourceWorkflowJob?.name}`}
+
+ ) : (
+ t`Deleted`
+ )
+ }
+ dataCy="wa-detail-source-job"
+ />
+ {workflowJob?.limit ? (
+
+ ) : null}
+ {workflowJob?.scm_branch ? (
+
+ ) : null}
+ {workflowJob?.summary_fields?.inventory ? (
+
+ {workflowJob.summary_fields.inventory?.name}
+
+ ) : (
+ ' '
+ )
+ }
+ dataCy="wa-detail-inventory"
+ />
+ ) : null}
+ {workflowJob?.summary_fields?.labels?.results?.length > 0 && (
+
+ {workflowJob.summary_fields.labels.results.map((label) => (
+
+ {label.name}
+
+ ))}
+
+ }
+ />
+ )}
+ {workflowJob?.extra_vars ? (
+
+ ) : null}
+
+
{workflowApproval.status === 'pending' &&
workflowApproval.can_approve_or_deny && (
diff --git a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js
index db22537fd0..5329373f0d 100644
--- a/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js
+++ b/awx/ui/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.js
@@ -1,6 +1,10 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { WorkflowApprovalsAPI } from 'api';
+import {
+ WorkflowApprovalsAPI,
+ WorkflowJobTemplatesAPI,
+ WorkflowJobsAPI,
+} from 'api';
import { formatDateString } from 'util/dates';
import {
mountWithContexts,
@@ -12,18 +16,278 @@ import mockWorkflowApprovals from '../data.workflowApprovals.json';
const workflowApproval = mockWorkflowApprovals.results[0];
jest.mock('../../../api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 218,
+ }),
+}));
+
+const workflowJobTemplate = {
+ id: 8,
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/8/',
+ related: {
+ named_url: '/api/v2/workflow_job_templates/00++/',
+ created_by: '/api/v2/users/1/',
+ modified_by: '/api/v2/users/1/',
+ last_job: '/api/v2/workflow_jobs/111/',
+ workflow_jobs: '/api/v2/workflow_job_templates/8/workflow_jobs/',
+ schedules: '/api/v2/workflow_job_templates/8/schedules/',
+ launch: '/api/v2/workflow_job_templates/8/launch/',
+ webhook_key: '/api/v2/workflow_job_templates/8/webhook_key/',
+ webhook_receiver: '/api/v2/workflow_job_templates/8/github/',
+ workflow_nodes: '/api/v2/workflow_job_templates/8/workflow_nodes/',
+ labels: '/api/v2/workflow_job_templates/8/labels/',
+ activity_stream: '/api/v2/workflow_job_templates/8/activity_stream/',
+ notification_templates_started:
+ '/api/v2/workflow_job_templates/8/notification_templates_started/',
+ notification_templates_success:
+ '/api/v2/workflow_job_templates/8/notification_templates_success/',
+ notification_templates_error:
+ '/api/v2/workflow_job_templates/8/notification_templates_error/',
+ notification_templates_approvals:
+ '/api/v2/workflow_job_templates/8/notification_templates_approvals/',
+ access_list: '/api/v2/workflow_job_templates/8/access_list/',
+ object_roles: '/api/v2/workflow_job_templates/8/object_roles/',
+ survey_spec: '/api/v2/workflow_job_templates/8/survey_spec/',
+ copy: '/api/v2/workflow_job_templates/8/copy/',
+ },
+ summary_fields: {
+ last_job: {
+ id: 111,
+ name: '00',
+ description: '',
+ finished: '2022-05-10T17:29:52.978531Z',
+ status: 'successful',
+ failed: false,
+ },
+ last_update: {
+ id: 111,
+ name: '00',
+ description: '',
+ status: 'successful',
+ failed: false,
+ },
+ created_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ modified_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ object_roles: {
+ admin_role: {
+ description: 'Can manage all aspects of the workflow job template',
+ name: 'Admin',
+ id: 34,
+ },
+ execute_role: {
+ description: 'May run the workflow job template',
+ name: 'Execute',
+ id: 35,
+ },
+ read_role: {
+ description: 'May view settings for the workflow job template',
+ name: 'Read',
+ id: 36,
+ },
+ approval_role: {
+ description: 'Can approve or deny a workflow approval node',
+ name: 'Approve',
+ id: 37,
+ },
+ },
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ start: true,
+ schedule: true,
+ copy: true,
+ },
+ labels: {
+ count: 1,
+ results: [
+ {
+ id: 2,
+ name: 'Test2',
+ },
+ ],
+ },
+ survey: {
+ title: '',
+ description: '',
+ },
+ recent_jobs: [
+ {
+ id: 111,
+ status: 'successful',
+ finished: '2022-05-10T17:29:52.978531Z',
+ canceled_on: null,
+ type: 'workflow_job',
+ },
+ {
+ id: 104,
+ status: 'failed',
+ finished: '2022-05-10T15:26:22.233170Z',
+ canceled_on: null,
+ type: 'workflow_job',
+ },
+ ],
+ },
+ created: '2022-05-05T14:13:36.123027Z',
+ modified: '2022-05-05T17:44:44.071447Z',
+ name: '00',
+ description: '',
+ last_job_run: '2022-05-10T17:29:52.978531Z',
+ last_job_failed: false,
+ next_job_run: null,
+ status: 'successful',
+ extra_vars: '{\n "foo": "bar",\n "baz": "qux"\n}',
+ organization: null,
+ survey_enabled: true,
+ allow_simultaneous: true,
+ ask_variables_on_launch: true,
+ inventory: null,
+ limit: null,
+ scm_branch: '',
+ ask_inventory_on_launch: true,
+ ask_scm_branch_on_launch: true,
+ ask_limit_on_launch: true,
+ webhook_service: 'github',
+ webhook_credential: null,
+};
+
+const workflowJob = {
+ id: 111,
+ type: 'workflow_job',
+ url: '/api/v2/workflow_jobs/111/',
+ related: {
+ created_by: '/api/v2/users/1/',
+ modified_by: '/api/v2/users/1/',
+ unified_job_template: '/api/v2/workflow_job_templates/8/',
+ workflow_job_template: '/api/v2/workflow_job_templates/8/',
+ notifications: '/api/v2/workflow_jobs/111/notifications/',
+ workflow_nodes: '/api/v2/workflow_jobs/111/workflow_nodes/',
+ labels: '/api/v2/workflow_jobs/111/labels/',
+ activity_stream: '/api/v2/workflow_jobs/111/activity_stream/',
+ relaunch: '/api/v2/workflow_jobs/111/relaunch/',
+ cancel: '/api/v2/workflow_jobs/111/cancel/',
+ },
+ summary_fields: {
+ inventory: {
+ id: 1,
+ name: 'Demo Inventory',
+ description: '',
+ has_active_failures: false,
+ total_hosts: 2,
+ hosts_with_active_failures: 0,
+ total_groups: 0,
+ has_inventory_sources: false,
+ total_inventory_sources: 0,
+ inventory_sources_with_failures: 0,
+ organization_id: 1,
+ kind: '',
+ },
+ workflow_job_template: {
+ id: 8,
+ name: '00',
+ description: '',
+ },
+ unified_job_template: {
+ id: 8,
+ name: '00',
+ description: '',
+ unified_job_type: 'workflow_job',
+ },
+ created_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ modified_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ user_capabilities: {
+ delete: true,
+ start: true,
+ },
+ labels: {
+ count: 1,
+ results: [
+ {
+ id: 2,
+ name: 'Test2',
+ },
+ ],
+ },
+ },
+ created: '2022-05-10T15:26:45.730965Z',
+ modified: '2022-05-10T15:26:46.150107Z',
+ name: '00',
+ description: '',
+ unified_job_template: 8,
+ launch_type: 'manual',
+ status: 'successful',
+ failed: false,
+ started: '2022-05-10T15:26:46.149825Z',
+ finished: '2022-05-10T17:29:52.978531Z',
+ canceled_on: null,
+ elapsed: 7386.829,
+ job_args: '',
+ job_cwd: '',
+ job_env: {},
+ job_explanation: '',
+ result_traceback: '',
+ launched_by: {
+ id: 1,
+ name: 'admin',
+ type: 'user',
+ url: '/api/v2/users/1/',
+ },
+ work_unit_id: null,
+ workflow_job_template: 8,
+ extra_vars: '{"foo": "bar", "baz": "qux", "first_one": 10}',
+ allow_simultaneous: true,
+ job_template: null,
+ is_sliced_job: false,
+ inventory: 1,
+ limit: 'localhost',
+ scm_branch: 'main',
+ webhook_service: '',
+ webhook_credential: null,
+ webhook_guid: '',
+};
describe('', () => {
- test('initially renders successfully', () => {
- mountWithContexts(
-
- );
+ beforeEach(() => {
+ WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
+ data: workflowJobTemplate,
+ });
+ WorkflowJobsAPI.readDetail.mockResolvedValue({ data: workflowJob });
});
- test('should render Details', () => {
- const wrapper = mountWithContexts(
-
- );
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should render Details', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
@@ -50,123 +314,169 @@ describe('', () => {
);
assertDetail('Last Modified', formatDateString(workflowApproval.modified));
assertDetail('Elapsed', '00:00:22');
+ assertDetail('Limit', 'localhost');
+ assertDetail('Source Control Branch', 'main');
+ const linkInventory = wrapper
+ .find('Detail[label="Inventory"]')
+ .find('Link');
+ expect(linkInventory.prop('to')).toEqual(
+ '/inventories/inventory/1/details'
+ );
+ assertDetail('Labels', 'Test2');
+ expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
+ '{"foo": "bar", "baz": "qux", "first_one": 10}'
+ );
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
});
- test('should show expiration date/time', () => {
- const wrapper = mountWithContexts(
-
- );
+ test('should show expiration date/time', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
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(
-
- );
+ test('should show finished date/time', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
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(
-
- );
+ test('should show canceled date/time', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
+
expect(wrapper.find(`Detail[label="Canceled"] dd`).text()).toBe(
`${formatDateString('2020-10-10T17:13:12.067947Z')}`
);
});
- test('should show explanation', () => {
- const wrapper = mountWithContexts(
-
- );
+ test('should show explanation', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find(`Detail[label="Explanation"] dd`).text()).toBe(
'Some explanation text'
);
});
- test('should show status when not pending', () => {
- const wrapper = mountWithContexts(
- {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
- );
+ }}
+ />
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('StatusLabel').text()).toBe('Approved');
});
- test('should show actor when available', () => {
- const wrapper = mountWithContexts(
- {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
- );
+ }}
+ />
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
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(
-
- );
+ test('action buttons should be hidden when user cannot approve or deny', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('WorkflowApprovalActionButtons').length).toBe(0);
});
- test('only the delete button should render when approval is not pending', () => {
- const wrapper = mountWithContexts(
-
- );
+
+ test('only the delete button should render when approval is not pending', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('WorkflowApprovalControls').length).toBe(0);
expect(wrapper.find('Button[aria-label="Approve"]').length).toBe(0);
expect(wrapper.find('DeleteButton').length).toBe(1);
@@ -176,9 +486,13 @@ describe('', () => {
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
Promise.reject(new Error())
);
- const wrapper = mountWithContexts(
-
- );
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
await act(async () => {
wrapper.find('DropdownToggleAction').invoke('onClick')();
});
@@ -202,9 +516,13 @@ describe('', () => {
WorkflowApprovalsAPI.deny.mockImplementationOnce(() =>
Promise.reject(new Error())
);
- const wrapper = mountWithContexts(
-
- );
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
await act(async () => wrapper.find('Toggle').prop('onToggle')(true));
wrapper.update();
await waitForElement(
@@ -232,45 +550,53 @@ describe('', () => {
);
});
- test('delete button should be hidden when user cannot delete', () => {
- const wrapper = mountWithContexts(
- {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
- );
+ }}
+ />
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('DeleteButton').length).toBe(0);
});
test('delete button should be hidden when job is pending and approve, action buttons should render', async () => {
- const wrapper = mountWithContexts(
- {
+ wrapper = mountWithContexts(
+
- );
+ can_approve_or_deny: true,
+ }}
+ />
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('DeleteButton').length).toBe(0);
expect(wrapper.find('WorkflowApprovalControls').length).toBe(1);
@@ -297,11 +623,15 @@ describe('', () => {
});
test('Delete button is visible and approve action is not', async () => {
- const wrapper = mountWithContexts(
-
- );
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
expect(wrapper.find('DeleteButton').length).toBe(1);
expect(wrapper.find('DeleteButton').prop('isDisabled')).toBe(false);
expect(wrapper.find('WorkflowApprovalControls').length).toBe(0);
@@ -311,21 +641,25 @@ describe('', () => {
WorkflowApprovalsAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())
);
- const wrapper = mountWithContexts(
- {
+ wrapper = mountWithContexts(
+
- );
+ }}
+ />
+ );
+ });
+ waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
await waitForElement(
wrapper,
'WorkflowApprovalDetail Button[aria-label="Delete"]'