Add pending approvals badge to application header

This commit is contained in:
mabashian 2021-02-16 16:25:19 -05:00
parent 99460a76d8
commit 1c61fafbc7
4 changed files with 284 additions and 18 deletions

View File

@ -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 (
<PageHeaderTools>
<PageHeaderToolsGroup>
<Tooltip position="left" content={<div>{i18n._(t`Info`)}</div>}>
<Tooltip
position="bottom"
content={i18n._(t`Pending Workflow Approvals`)}
>
<PageHeaderToolsItem>
<Link to="/workflow_approvals?workflow_approvals.status=pending">
<PendingWorkflowApprovals>
<BellIcon color="white" />
<PendingWorkflowApprovalBadge
id="toolbar-workflow-approval-badge"
isRead
>
{pendingApprovalsCount}
</PendingWorkflowApprovalBadge>
</PendingWorkflowApprovals>
</Link>
</PageHeaderToolsItem>
</Tooltip>
<Tooltip position="bottom" content={<div>{i18n._(t`Info`)}</div>}>
<PageHeaderToolsItem>
<Dropdown
isPlain

View File

@ -1,6 +1,12 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import PageHeaderToolbar from './PageHeaderToolbar';
import { WorkflowApprovalsAPI } from '../../api';
jest.mock('../../api');
let wrapper;
describe('PageHeaderToolbar', () => {
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(
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
/>
);
afterEach(() => {
wrapper.unmount();
});
test('expected content is rendered on initialization', async () => {
await act(async () => {
wrapper = mountWithContexts(
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
/>
);
});
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(
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
loggedInUser={{ id: 1 }}
/>
);
test('dropdowns have expected items and callbacks', async () => {
await act(async () => {
wrapper = mountWithContexts(
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
loggedInUser={{ id: 1 }}
/>
);
});
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(
<PageHeaderToolbar
onAboutClick={onAboutClick}
onLogoutClick={onLogoutClick}
/>
);
});
expect(
wrapper.find('Badge#toolbar-workflow-approval-badge').text()
).toEqual('20');
});
});

View File

@ -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;
}

View File

@ -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 dont 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 <div />;
}
function Test({ initialCount, fetchApprovalsCount }) {
const updatedWorkflowApprovals = useWsPendingApprovalCount(
initialCount,
fetchApprovalsCount
);
return <TestInner initialCount={updatedWorkflowApprovals} />;
}
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(
<Test initialCount={2} fetchApprovalsCount={() => {}} />
);
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(
<Test initialCount={2} fetchApprovalsCount={() => {}} />
);
});
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(
<Test initialCount={2} fetchApprovalsCount={fetchApprovalsCount} />
);
});
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(
<Test initialCount={2} fetchApprovalsCount={fetchApprovalsCount} />
);
});
await mockServer.connected;
await act(async () => {
mockServer.send(
JSON.stringify({
unified_job_id: 1,
type: 'job',
status: 'successful',
})
);
});
expect(fetchApprovalsCount).toHaveBeenCalledTimes(0);
});
});