From ee7f73623f91cad6bf2a6f78eab703bd43959a68 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 12 Oct 2020 09:36:22 -0400 Subject: [PATCH] Adds /#/workflow_approvals list and details and allows users to approve or deny workflow approvals from these interfaces --- awx/ui_next/src/api/index.js | 3 + .../src/api/models/WorkflowApprovals.js | 18 + awx/ui_next/src/routeConfig.js | 6 + .../NotificationTemplateList.jsx | 4 +- .../WorkflowApproval/WorkflowApproval.jsx | 116 +++++++ .../WorkflowApproval.test.jsx | 56 ++++ .../WorkflowApprovalDetail.jsx | 171 ++++++++++ .../WorkflowApprovalDetail.test.jsx | 208 ++++++++++++ .../WorkflowApprovalDetail/index.js | 3 + .../WorkflowApprovalList.jsx | 187 +++++++++++ .../WorkflowApprovalList.test.jsx | 317 ++++++++++++++++++ .../WorkflowApprovalListItem.jsx | 119 +++++++ .../WorkflowApprovalListItem.test.jsx | 55 +++ .../WorkflowApprovalList/index.js | 4 + .../useWsWorkflowApprovals.js | 61 ++++ .../useWsWorkflowApprovals.test.jsx | 163 +++++++++ .../WorkflowApproval/WorkflowApprovals.jsx | 42 +++ .../WorkflowApprovals.test.jsx | 35 ++ .../data.workflowApproval.json | 71 ++++ .../src/screens/WorkflowApproval/index.js | 1 + .../shared/WorkflowApprovalActionButtons.jsx | 115 +++++++ .../WorkflowApprovalActionButtons.test.jsx | 94 ++++++ .../shared/WorkflowApprovalStatus.jsx | 69 ++++ .../shared/WorkflowApprovalStatus.test.jsx | 94 ++++++ awx/ui_next/src/types.js | 16 + awx/ui_next/src/util/dates.jsx | 6 + awx/ui_next/src/util/dates.test.jsx | 10 + awx/ui_next/src/util/useThrottle.js | 8 +- 28 files changed, 2049 insertions(+), 3 deletions(-) create mode 100644 awx/ui_next/src/api/models/WorkflowApprovals.js create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApproval.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/WorkflowApprovalDetail.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalDetail/index.js create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalListItem.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/index.js create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.js create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovalList/useWsWorkflowApprovals.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/WorkflowApprovals.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/data.workflowApproval.json create mode 100644 awx/ui_next/src/screens/WorkflowApproval/index.js create mode 100644 awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalActionButtons.test.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.jsx create mode 100644 awx/ui_next/src/screens/WorkflowApproval/shared/WorkflowApprovalStatus.test.jsx 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 ( + + + + ); + } + + if (workflowApproval.status === 'successful') { + return ( + + + + ); + } + + 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));