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
This commit is contained in:
Tim Zallmann 2019-07-09 08:44:19 +00:00 committed by Kushal Pandya
parent 9d07919465
commit b9e52612fe
15 changed files with 328 additions and 2 deletions

View File

@ -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, {

View File

@ -4,3 +4,6 @@ import './jquery';
import './bootstrap';
import './vue';
import '../lib/utils/axios_utils';
import { openUserCountsBroadcast } from './nav/user_merge_requests';
openUserCountsBroadcast();

View File

@ -0,0 +1,67 @@
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);
};
}
}
}

View File

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

View File

@ -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'));

View File

@ -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) {

View File

@ -0,0 +1,6 @@
---
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

View File

@ -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).

View File

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

18
lib/api/user_counts.rb Normal file
View File

@ -0,0 +1,18 @@
# 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

View File

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

View File

@ -0,0 +1,113 @@
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();
});
});
});
});

View File

@ -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', () => {

View File

@ -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();

View File

@ -0,0 +1,40 @@
# 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