Commit 5e8d6c42 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'tz-update-mr-count-over-tabs' into 'master'

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

See merge request gitlab-org/gitlab-ce!29441
parents 9d079194 b9e52612
......@@ -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