mirror of
https://github.com/ansible/awx.git
synced 2026-02-19 20:20:06 -03:30
Merge pull request #9334 from mabashian/8372-pending-approvals
Add pending approvals badge to application header Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com> https://github.com/tiagodread
This commit is contained in:
@@ -14,6 +14,7 @@ This is a list of high-level changes for each release of AWX. A full list of com
|
|||||||
- Fixed a bug where launch prompt inputs were unexpectedly deposited in the url: https://github.com/ansible/awx/pull/9231
|
- Fixed a bug where launch prompt inputs were unexpectedly deposited in the url: https://github.com/ansible/awx/pull/9231
|
||||||
- Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120
|
- Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120
|
||||||
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
|
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
|
||||||
|
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
|
||||||
|
|
||||||
# 17.0.1 (January 26, 2021)
|
# 17.0.1 (January 26, 2021)
|
||||||
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
DropdownToggle,
|
DropdownToggle,
|
||||||
@@ -12,7 +15,25 @@ import {
|
|||||||
PageHeaderToolsItem,
|
PageHeaderToolsItem,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@patternfly/react-core';
|
} 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 =
|
const DOCLINK =
|
||||||
'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html';
|
'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html';
|
||||||
@@ -27,6 +48,31 @@ function PageHeaderToolbar({
|
|||||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||||
const [isUserOpen, setIsUserOpen] = 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 = () => {
|
const handleHelpSelect = () => {
|
||||||
setIsHelpOpen(!isHelpOpen);
|
setIsHelpOpen(!isHelpOpen);
|
||||||
};
|
};
|
||||||
@@ -37,7 +83,25 @@ function PageHeaderToolbar({
|
|||||||
return (
|
return (
|
||||||
<PageHeaderTools>
|
<PageHeaderTools>
|
||||||
<PageHeaderToolsGroup>
|
<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>
|
<PageHeaderToolsItem>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
isPlain
|
isPlain
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
import PageHeaderToolbar from './PageHeaderToolbar';
|
||||||
|
import { WorkflowApprovalsAPI } from '../../api';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
describe('PageHeaderToolbar', () => {
|
describe('PageHeaderToolbar', () => {
|
||||||
const pageHelpDropdownSelector = 'Dropdown QuestionCircleIcon';
|
const pageHelpDropdownSelector = 'Dropdown QuestionCircleIcon';
|
||||||
@@ -8,26 +14,39 @@ describe('PageHeaderToolbar', () => {
|
|||||||
const onAboutClick = jest.fn();
|
const onAboutClick = jest.fn();
|
||||||
const onLogoutClick = jest.fn();
|
const onLogoutClick = jest.fn();
|
||||||
|
|
||||||
test('expected content is rendered on initialization', () => {
|
afterEach(() => {
|
||||||
const wrapper = mountWithContexts(
|
wrapper.unmount();
|
||||||
<PageHeaderToolbar
|
});
|
||||||
onAboutClick={onAboutClick}
|
|
||||||
onLogoutClick={onLogoutClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
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(pageHelpDropdownSelector)).toHaveLength(1);
|
||||||
expect(wrapper.find(pageUserDropdownSelector)).toHaveLength(1);
|
expect(wrapper.find(pageUserDropdownSelector)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dropdowns have expected items and callbacks', () => {
|
test('dropdowns have expected items and callbacks', async () => {
|
||||||
const wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<PageHeaderToolbar
|
wrapper = mountWithContexts(
|
||||||
onAboutClick={onAboutClick}
|
<PageHeaderToolbar
|
||||||
onLogoutClick={onLogoutClick}
|
onAboutClick={onAboutClick}
|
||||||
loggedInUser={{ id: 1 }}
|
onLogoutClick={onLogoutClick}
|
||||||
/>
|
loggedInUser={{ id: 1 }}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||||
wrapper.find(pageHelpDropdownSelector).simulate('click');
|
wrapper.find(pageHelpDropdownSelector).simulate('click');
|
||||||
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
||||||
@@ -48,4 +67,24 @@ describe('PageHeaderToolbar', () => {
|
|||||||
logout.simulate('click');
|
logout.simulate('click');
|
||||||
expect(onLogoutClick).toHaveBeenCalled();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user