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( -