Commit b9e52612 authored by Tim Zallmann's avatar Tim Zallmann Committed by Kushal Pandya

Updates on success of an MR the count on top and in other tabs

New API endpoint for merge request count
Updates all open tabs at the same time with one call
Restructured API response

API response changed to 401 if no current_user

Added API + JS specs

Fix for Static Check

Updated Count on Open/Close, Assign/Unassign of MR's

Checking if MR Count is refreshed

Added # frozen_string_literal: true to spec

Added Changelog
parent 9d079194
......@@ -24,6 +24,7 @@ const Api = {
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
userCountsPath: '/api/:version/user_counts',
usersPath: '/api/:version/users.json',
userPath: '/api/:version/users/:id',
userStatusPath: '/api/:version/users/:id/status',
......@@ -312,6 +313,11 @@ const Api = {
});
},
userCounts() {
const url = Api.buildUrl(this.userCountsPath);
return axios.get(url);
},
userStatus(id, options) {
const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
......
......@@ -4,3 +4,6 @@ import './jquery';
import './bootstrap';
import './vue';
import '../lib/utils/axios_utils';
import { openUserCountsBroadcast } from './nav/user_merge_requests';
openUserCountsBroadcast();
import Api from '~/api';
let channel;
function broadcastCount(newCount) {
if (!channel) {
return;
}
channel.postMessage(newCount);
}
function updateUserMergeRequestCounts(newCount) {
const mergeRequestsCountEl = document.querySelector('.merge-requests-count');
mergeRequestsCountEl.textContent = newCount.toLocaleString();
mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0);
}
/**
* Refresh user counts (and broadcast if open)
*/
export function refreshUserMergeRequestCounts() {
return Api.userCounts()
.then(({ data }) => {
const count = data.merge_requests;
updateUserMergeRequestCounts(count);
broadcastCount(count);
})
.catch(ex => {
console.error(ex); // eslint-disable-line no-console
});
}
/**
* Close the broadcast channel for user counts
*/
export function closeUserCountsBroadcast() {
if (!channel) {
return;
}
channel.close();
channel = null;
}
/**
* Open the broadcast channel for user counts, adds user id so we only update
*
* **Please note:**
* Not supported in all browsers, but not polyfilling for now
* to keep bundle size small and
* no special functionality lost except cross tab notifications
*/
export function openUserCountsBroadcast() {
closeUserCountsBroadcast();
if (window.BroadcastChannel) {
const currentUserId = typeof gon !== 'undefined' && gon && gon.current_user_id;
if (currentUserId) {
channel = new BroadcastChannel(`mr_count_channel_${currentUserId}`);
channel.onmessage = ev => {
updateUserMergeRequestCounts(ev.data);
};
}
}
}
......@@ -13,6 +13,7 @@ import {
splitCamelCase,
slugifyWithUnderscore,
} from '../../lib/utils/text_utility';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
......@@ -234,7 +235,10 @@ export default {
toggleIssueState() {
if (this.isOpen) {
this.closeIssue()
.then(() => this.enableButton())
.then(() => {
this.enableButton();
refreshUserMergeRequestCounts();
})
.catch(() => {
this.enableButton();
this.toggleStateButtonLoading(false);
......@@ -247,7 +251,10 @@ export default {
});
} else {
this.reopenIssue()
.then(() => this.enableButton())
.then(() => {
this.enableButton();
refreshUserMergeRequestCounts();
})
.catch(({ data }) => {
this.enableButton();
this.toggleStateButtonLoading(false);
......
......@@ -2,6 +2,7 @@
import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
import { __ } from '~/locale';
......@@ -73,6 +74,9 @@ export default {
this.mediator
.saveAssignees(this.field)
.then(setLoadingFalse.bind(this))
.then(() => {
refreshUserMergeRequestCounts();
})
.catch(() => {
setLoadingFalse();
return new Flash(__('Error occurred when saving assignees'));
......
......@@ -6,6 +6,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import { __ } from '~/locale';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import MergeRequest from '../../../merge_request';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
......@@ -174,6 +175,8 @@ export default {
MergeRequest.decreaseCounter();
stopPolling();
refreshUserMergeRequestCounts();
// If user checked remove source branch and we didn't remove the branch yet
// we should start another polling for source branch remove process
if (this.removeSourceBranch && data.source_branch_exists) {
......
---
title: New API for User Counts, updates on success of an MR the count on top and in
other tabs
merge_request: 29441
author:
type: added
......@@ -593,6 +593,30 @@ Example responses
}
```
## User counts
Get the counts (same as in top right menu) of the currently signed in user.
| Attribute | Type | Description |
| --------- | ---- | ----------- |
| `merge_requests` | number | Merge requests that are active and assigned to current user. |
```
GET /user_counts
```
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/user_counts"
```
Example response:
```json
{
"merge_requests": 4
}
```
## List user projects
Please refer to the [List of user projects](projects.md#list-user-projects).
......
......@@ -166,6 +166,7 @@ module API
mount ::API::Templates
mount ::API::Todos
mount ::API::Triggers
mount ::API::UserCounts
mount ::API::Users
mount ::API::Variables
mount ::API::Version
......
# frozen_string_literal: true
module API
class UserCounts < Grape::API
resource :user_counts do
desc 'Return the user specific counts' do
detail 'Open MR Count'
end
get do
unauthorized! unless current_user
{
merge_requests: current_user.assigned_open_merge_requests_count
}
end
end
end
end
......@@ -412,6 +412,22 @@ describe('Api', () => {
});
});
describe('user counts', () => {
it('fetches single user counts', done => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`;
mock.onGet(expectedUrl).reply(200, {
merge_requests: 4,
});
Api.userCounts()
.then(({ data }) => {
expect(data.merge_requests).toBe(4);
})
.then(done)
.catch(done.fail);
});
});
describe('user status', () => {
it('fetches single user status', done => {
const userId = '123456';
......
import {
openUserCountsBroadcast,
closeUserCountsBroadcast,
refreshUserMergeRequestCounts,
} from '~/commons/nav/user_merge_requests';
import Api from '~/api';
jest.mock('~/api');
const TEST_COUNT = 1000;
const MR_COUNT_CLASS = 'merge-requests-count';
describe('User Merge Requests', () => {
let channelMock;
let newBroadcastChannelMock;
beforeEach(() => {
global.gon.current_user_id = 123;
channelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock);
global.BroadcastChannel = newBroadcastChannelMock;
setFixtures(`<div class="${MR_COUNT_CLASS}">0</div>`);
});
const findMRCountText = () => document.body.querySelector(`.${MR_COUNT_CLASS}`).textContent;
describe('refreshUserMergeRequestCounts', () => {
beforeEach(() => {
Api.userCounts.mockReturnValue(
Promise.resolve({
data: { merge_requests: TEST_COUNT },
}),
);
});
describe('with open broadcast channel', () => {
beforeEach(() => {
openUserCountsBroadcast();
return refreshUserMergeRequestCounts();
});
it('updates the top count of merge requests', () => {
expect(findMRCountText()).toEqual(TEST_COUNT.toLocaleString());
});
it('calls the API', () => {
expect(Api.userCounts).toHaveBeenCalled();
});
it('posts count to BroadcastChannel', () => {
expect(channelMock.postMessage).toHaveBeenCalledWith(TEST_COUNT);
});
});
describe('without open broadcast channel', () => {
beforeEach(() => refreshUserMergeRequestCounts());
it('does not post anything', () => {
expect(channelMock.postMessage).not.toHaveBeenCalled();
});
});
});
describe('openUserCountsBroadcast', () => {
beforeEach(() => {
openUserCountsBroadcast();
});
it('creates BroadcastChannel that updates DOM on message received', () => {
expect(findMRCountText()).toEqual('0');
channelMock.onmessage({ data: TEST_COUNT });
expect(findMRCountText()).toEqual(TEST_COUNT.toLocaleString());
});
it('closes if called while already open', () => {
expect(channelMock.close).not.toHaveBeenCalled();
openUserCountsBroadcast();
expect(channelMock.close).toHaveBeenCalled();
});
});
describe('closeUserCountsBroadcast', () => {
describe('when not opened', () => {
it('does nothing', () => {
expect(channelMock.close).not.toHaveBeenCalled();
});
});
describe('when opened', () => {
beforeEach(() => {
openUserCountsBroadcast();
});
it('closes', () => {
expect(channelMock.close).not.toHaveBeenCalled();
closeUserCountsBroadcast();
expect(channelMock.close).toHaveBeenCalled();
});
});
});
});
......@@ -251,6 +251,21 @@ describe('issue_comment_form component', () => {
});
});
});
describe('when toggling state', () => {
it('should update MR count', done => {
spyOn(vm, 'closeIssue').and.returnValue(Promise.resolve());
const updateMrCountSpy = spyOnDependency(CommentForm, 'refreshUserMergeRequestCounts');
vm.toggleIssueState();
Vue.nextTick(() => {
expect(updateMrCountSpy).toHaveBeenCalled();
done();
});
});
});
});
describe('issue is confidential', () => {
......
......@@ -58,9 +58,11 @@ const createComponent = (customConfig = {}) => {
describe('ReadyToMerge', () => {
let vm;
let updateMrCountSpy;
beforeEach(() => {
vm = createComponent();
updateMrCountSpy = spyOnDependency(ReadyToMerge, 'refreshUserMergeRequestCounts');
});
afterEach(() => {
......@@ -461,6 +463,7 @@ describe('ReadyToMerge', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
expect(eventHub.$emit).toHaveBeenCalledWith('FetchActionsContent');
expect(vm.initiateRemoveSourceBranchPolling).toHaveBeenCalled();
expect(updateMrCountSpy).toHaveBeenCalled();
expect(cpc).toBeFalsy();
expect(spc).toBeTruthy();
......
# frozen_string_literal: true
require 'spec_helper'
describe API::UserCounts do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let!(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, title: "Test") }
describe 'GET /user_counts' do
context 'when unauthenticated' do
it 'returns authentication error' do
get api('/user_counts')
expect(response.status).to eq(401)
end
end
context 'when authenticated' do
it 'returns open counts for current user' do
get api('/user_counts', user)
expect(response.status).to eq(200)
expect(json_response).to be_a Hash
expect(json_response['merge_requests']).to eq(1)
end
it 'updates the mr count when a new mr is assigned' do
create(:merge_request, source_project: project, author: user, assignees: [user])
get api('/user_counts', user)
expect(response.status).to eq(200)
expect(json_response).to be_a Hash
expect(json_response['merge_requests']).to eq(2)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment