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