diff --git a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx index 1b8bb0c7e4..1238665601 100644 --- a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx +++ b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx @@ -1,8 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; import { + Badge, Dropdown, DropdownItem, DropdownToggle, @@ -12,7 +15,25 @@ import { PageHeaderToolsItem, Tooltip, } from '@patternfly/react-core'; -import { QuestionCircleIcon, UserIcon } from '@patternfly/react-icons'; +import { + BellIcon, + QuestionCircleIcon, + UserIcon, +} from '@patternfly/react-icons'; +import { WorkflowApprovalsAPI } from '../../api'; +import useRequest from '../../util/useRequest'; +import useWsPendingApprovalCount from './useWsPendingApprovalCount'; + +const PendingWorkflowApprovals = styled.div` + display: flex; + align-items: center; + padding: 10px; + margin-right: 10px; +`; + +const PendingWorkflowApprovalBadge = styled(Badge)` + margin-left: 10px; +`; const DOCLINK = 'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html'; @@ -27,6 +48,31 @@ function PageHeaderToolbar({ const [isHelpOpen, setIsHelpOpen] = useState(false); const [isUserOpen, setIsUserOpen] = useState(false); + const { + request: fetchPendingApprovalCount, + result: pendingApprovals, + } = useRequest( + useCallback(async () => { + const { + data: { count }, + } = await WorkflowApprovalsAPI.read({ + status: 'pending', + page_size: 1, + }); + return count; + }, []), + 0 + ); + + const pendingApprovalsCount = useWsPendingApprovalCount( + pendingApprovals, + fetchPendingApprovalCount + ); + + useEffect(() => { + fetchPendingApprovalCount(); + }, [fetchPendingApprovalCount]); + const handleHelpSelect = () => { setIsHelpOpen(!isHelpOpen); }; @@ -37,7 +83,25 @@ function PageHeaderToolbar({ return ( - {i18n._(t`Info`)}}> + + + + + + + {pendingApprovalsCount} + + + + + + {i18n._(t`Info`)}}> { const pageHelpDropdownSelector = 'Dropdown QuestionCircleIcon'; @@ -8,26 +14,39 @@ describe('PageHeaderToolbar', () => { const onAboutClick = jest.fn(); const onLogoutClick = jest.fn(); - test('expected content is rendered on initialization', () => { - const wrapper = mountWithContexts( - - ); + afterEach(() => { + wrapper.unmount(); + }); + test('expected content is rendered on initialization', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect( + wrapper.find( + 'Link[to="/workflow_approvals?workflow_approvals.status=pending"]' + ) + ).toHaveLength(1); expect(wrapper.find(pageHelpDropdownSelector)).toHaveLength(1); expect(wrapper.find(pageUserDropdownSelector)).toHaveLength(1); }); - test('dropdowns have expected items and callbacks', () => { - const wrapper = mountWithContexts( - - ); + test('dropdowns have expected items and callbacks', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(wrapper.find('DropdownItem')).toHaveLength(0); wrapper.find(pageHelpDropdownSelector).simulate('click'); expect(wrapper.find('DropdownItem')).toHaveLength(2); @@ -48,4 +67,24 @@ describe('PageHeaderToolbar', () => { logout.simulate('click'); expect(onLogoutClick).toHaveBeenCalled(); }); + + test('pending workflow approvals count set correctly', async () => { + WorkflowApprovalsAPI.read.mockResolvedValueOnce({ + data: { + count: 20, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect( + wrapper.find('Badge#toolbar-workflow-approval-badge').text() + ).toEqual('20'); + }); }); diff --git a/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js new file mode 100644 index 0000000000..d6b1edde4a --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../util/useWebsocket'; +import useThrottle from '../../util/useThrottle'; + +export default function useWsPendingApprovalCount( + initialCount, + fetchApprovalsCount +) { + const [pendingApprovalCount, setPendingApprovalCount] = useState( + initialCount + ); + const [reloadCount, setReloadCount] = useState(false); + const throttledFetch = useThrottle(reloadCount, 1000); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setPendingApprovalCount(initialCount); + }, [initialCount]); + + useEffect( + function reloadTheCount() { + (async () => { + if (!throttledFetch) { + return; + } + setReloadCount(false); + fetchApprovalsCount(); + })(); + }, + [throttledFetch, fetchApprovalsCount] + ); + + useEffect( + function processWsMessage() { + if (lastMessage?.type === 'workflow_approval') { + setReloadCount(true); + } + }, + [lastMessage] + ); + + return pendingApprovalCount; +} diff --git a/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx new file mode 100644 index 0000000000..4e067d6c9c --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import useWsPendingApprovalCount from './useWsPendingApprovalCount'; + +/* + 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({ initialCount, fetchApprovalsCount }) { + const updatedWorkflowApprovals = useWsPendingApprovalCount( + initialCount, + fetchApprovalsCount + ); + return ; +} + +describe('useWsPendingApprovalCount 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 approval pending count', () => { + wrapper = mountWithContexts( + {}} /> + ); + + expect(wrapper.find('TestInner').prop('initialCount')).toEqual(2); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + 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 count after approval status changes', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + const fetchApprovalsCount = 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(fetchApprovalsCount).toHaveBeenCalledTimes(1); + }); + + test('should not refetch when message is not workflow approval', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + const fetchApprovalsCount = 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(fetchApprovalsCount).toHaveBeenCalledTimes(0); + }); +});