Async notification subscriptions in issue boards

This commit is contained in:
Eric Eastwood 2017-11-14 00:05:40 -06:00 committed by Oswaldo Ferreira
parent d2699aea57
commit f494dbc515
17 changed files with 164 additions and 98 deletions

View File

@ -1,12 +1,13 @@
/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ /* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
/* global BoardService */
import _ from 'underscore'; import _ from 'underscore';
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import Flash from '../flash'; import Flash from '../flash';
import { __ } from '../locale';
import FilteredSearchBoards from './filtered_search_boards'; import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub'; import eventHub from './eventhub';
import sidebarEventHub from '../sidebar/event_hub';
import './models/issue'; import './models/issue';
import './models/label'; import './models/label';
import './models/list'; import './models/list';
@ -14,7 +15,7 @@ import './models/milestone';
import './models/assignee'; import './models/assignee';
import './stores/boards_store'; import './stores/boards_store';
import './stores/modal_store'; import './stores/modal_store';
import './services/board_service'; import BoardService from './services/board_service';
import './mixins/modal_mixins'; import './mixins/modal_mixins';
import './mixins/sortable_default_options'; import './mixins/sortable_default_options';
import './filters/due_date_filters'; import './filters/due_date_filters';
@ -77,11 +78,16 @@ $(() => {
}); });
Store.rootPath = this.boardsEndpoint; Store.rootPath = this.boardsEndpoint;
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens); eventHub.$on('updateTokens', this.updateTokens);
eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens); eventHub.$off('updateTokens', this.updateTokens);
eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
}, },
mounted () { mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true); this.filterManager = new FilteredSearchBoards(Store.filter, true);
@ -112,6 +118,46 @@ $(() => {
methods: { methods: {
updateTokens() { updateTokens() {
this.filterManager.updateTokens(); this.filterManager.updateTokens();
},
updateDetailIssue(newIssue) {
const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.json())
.then((data) => {
newIssue.setFetchingState('subscriptions', false);
newIssue.updateData({
subscribed: data.subscribed,
});
})
.catch(() => {
newIssue.setFetchingState('subscriptions', false);
Flash(__('An error occurred while fetching sidebar data'));
});
}
Store.detail.issue = newIssue;
},
clearDetailIssue() {
Store.detail.issue = {};
},
toggleSubscription(id) {
const issue = Store.detail.issue;
if (issue.id === id && issue.toggleSubscriptionEndpoint) {
issue.setFetchingState('subscriptions', true);
BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
.then(() => {
issue.setFetchingState('subscriptions', false);
issue.updateData({
subscribed: !issue.subscribed,
});
})
.catch(() => {
issue.setFetchingState('subscriptions', false);
Flash(__('An error occurred when toggling the notification subscription'));
});
}
} }
}, },
}); });

View File

@ -1,25 +1,11 @@
<script>
import './issue_card_inner'; import './issue_card_inner';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardsIssueCard', name: 'BoardsIssueCard',
template: `
<li class="card"
:class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
:index="index"
:data-issue-id="issue.id"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)">
<issue-card-inner
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:update-filters="true" />
</li>
`,
components: { components: {
'issue-card-inner': gl.issueBoards.IssueCardInner, 'issue-card-inner': gl.issueBoards.IssueCardInner,
}, },
@ -56,12 +42,30 @@ export default {
this.showDetail = false; this.showDetail = false;
if (Store.detail.issue && Store.detail.issue.id === this.issue.id) { if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
Store.detail.issue = {}; eventHub.$emit('clearDetailIssue');
} else { } else {
Store.detail.issue = this.issue; eventHub.$emit('newDetailIssue', this.issue);
Store.detail.list = this.list; Store.detail.list = this.list;
} }
} }
}, },
}, },
}; };
</script>
<template>
<li class="card"
:class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
:index="index"
:data-issue-id="issue.id"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)">
<issue-card-inner
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:update-filters="true" />
</li>
</template>

View File

@ -1,6 +1,6 @@
/* global Sortable */ /* global Sortable */
import boardNewIssue from './board_new_issue'; import boardNewIssue from './board_new_issue';
import boardCard from './board_card'; import boardCard from './board_card.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';

View File

@ -5,12 +5,13 @@
import Vue from 'vue'; import Vue from 'vue';
import Flash from '../../flash'; import Flash from '../../flash';
import eventHub from '../../sidebar/event_hub'; import eventHub from '../../sidebar/event_hub';
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
import Assignees from '../../sidebar/components/assignees/assignees'; import assignees from '../../sidebar/components/assignees/assignees';
import DueDateSelectors from '../../due_date_select'; import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue'; import './sidebar/remove_issue';
import IssuableContext from '../../issuable_context'; import IssuableContext from '../../issuable_context';
import LabelsSelect from '../../labels_select'; import LabelsSelect from '../../labels_select';
import subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
@ -117,11 +118,11 @@ gl.issueBoards.BoardSidebar = Vue.extend({
new DueDateSelectors(); new DueDateSelectors();
new LabelsSelect(); new LabelsSelect();
new Sidebar(); new Sidebar();
gl.Subscription.bindAll('.subscription');
}, },
components: { components: {
assigneeTitle,
assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn, removeBtn: gl.issueBoards.RemoveIssueBtn,
'assignee-title': AssigneeTitle, subscriptions,
assignees: Assignees,
}, },
}); });

View File

@ -17,6 +17,11 @@ class ListIssue {
this.assignees = []; this.assignees = [];
this.selected = false; this.selected = false;
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
this.isFetching = {
subscriptions: true,
};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
@ -73,6 +78,14 @@ class ListIssue {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
} }
updateData(newData) {
Object.assign(this, newData);
}
setFetchingState(key, value) {
this.isFetching[key] = value;
}
update (url) { update (url) {
const data = { const data = {
issue: { issue: {

View File

@ -2,7 +2,7 @@
import Vue from 'vue'; import Vue from 'vue';
class BoardService { export default class BoardService {
constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) { constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, { this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: { issues: {
@ -88,6 +88,14 @@ class BoardService {
return this.issues.bulkUpdate(data); return this.issues.bulkUpdate(data);
} }
static getIssueInfo(endpoint) {
return Vue.http.get(endpoint);
}
static toggleIssueSubscription(endpoint) {
return Vue.http.post(endpoint);
}
} }
window.BoardService = BoardService; window.BoardService = BoardService;

View File

@ -14,7 +14,6 @@ export default () => {
}); });
new LabelsSelect(); new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser); new IssuableContext(sidebarOptions.currentUser);
gl.Subscription.bindAll('.subscription');
new DueDateSelectors(); new DueDateSelectors();
window.sidebar = new Sidebar(); window.sidebar = new Sidebar();
}; };

View File

@ -80,7 +80,6 @@ import './right_sidebar';
import './search'; import './search';
import './search_autocomplete'; import './search_autocomplete';
import './smart_interval'; import './smart_interval';
import './subscription';
import './subscription_select'; import './subscription_select';
import initBreadcrumbs from './breadcrumb'; import initBreadcrumbs from './breadcrumb';

View File

@ -3,6 +3,7 @@ import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator'; import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import Flash from '../../../flash'; import Flash from '../../../flash';
import { __ } from '../../../locale';
import subscriptions from './subscriptions.vue'; import subscriptions from './subscriptions.vue';
export default { export default {
@ -21,7 +22,7 @@ export default {
onToggleSubscription() { onToggleSubscription() {
this.mediator.toggleSubscription() this.mediator.toggleSubscription()
.catch(() => { .catch(() => {
Flash('Error occurred when toggling the notification subscription'); Flash(__('Error occurred when toggling the notification subscription'));
}); });
}, },
}, },

View File

@ -14,6 +14,10 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
}, },
id: {
type: Number,
required: false,
},
}, },
components: { components: {
loadingButton, loadingButton,
@ -32,7 +36,7 @@ export default {
}, },
methods: { methods: {
toggleSubscription() { toggleSubscription() {
eventHub.$emit('toggleSubscription'); eventHub.$emit('toggleSubscription', this.id);
}, },
}, },
}; };

View File

@ -1,45 +0,0 @@
class Subscription {
constructor(containerElm) {
this.containerElm = containerElm;
const subscribeButton = containerElm.querySelector('.js-subscribe-button');
if (subscribeButton) {
// remove class so we don't bind twice
subscribeButton.classList.remove('js-subscribe-button');
subscribeButton.addEventListener('click', this.toggleSubscription.bind(this));
}
}
toggleSubscription(event) {
const button = event.currentTarget;
const buttonSpan = button.querySelector('span');
if (!buttonSpan || button.classList.contains('disabled')) {
return;
}
button.classList.add('disabled');
const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
const toggleActionUrl = this.containerElm.dataset.url;
$.post(toggleActionUrl, () => {
button.classList.remove('disabled');
// hack to allow this to work with the issue boards Vue object
if (document.querySelector('html').classList.contains('issue-boards-page')) {
gl.issueBoards.boardStoreIssueSet(
'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed,
);
} else {
buttonSpan.innerHTML = isSubscribed ? 'Subscribe' : 'Unsubscribe';
}
});
}
static bindAll(selector) {
[].forEach.call(document.querySelectorAll(selector), elm => new Subscription(elm));
}
}
window.gl = window.gl || {};
window.gl.Subscription = Subscription;

View File

@ -1,7 +1,5 @@
- if current_user - if current_user
.block.light.subscription{ ":data-url" => "'#{build_issue_link_base}/' + issue.iid + '/toggle_subscription'" } .block.subscriptions
%span.issuable-header-text.hide-collapsed.pull-left %subscriptions{ ":loading" => "issue.isFetching && issue.isFetching.subscriptions",
Notifications ":subscribed" => "issue.subscribed",
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } ":id" => "issue.id" }
%span
{{issue.subscribed ? 'Unsubscribe' : 'Subscribe'}}

View File

@ -0,0 +1,5 @@
---
title: Update Issue Boards to fetch the notification subscription status asynchronously
merge_request:
author:
type: performance

View File

@ -331,11 +331,29 @@ describe 'Issue Boards', :js do
context 'subscription' do context 'subscription' do
it 'changes issue subscription' do it 'changes issue subscription' do
click_card(card) click_card(card)
wait_for_requests
page.within('.subscription') do page.within('.subscriptions') do
click_button 'Subscribe' click_button 'Subscribe'
wait_for_requests wait_for_requests
expect(page).to have_content("Unsubscribe")
expect(page).to have_content('Unsubscribe')
end
end
it 'has "Unsubscribe" button when already subscribed' do
create(:subscription, user: user, project: project, subscribable: issue2, subscribed: true)
visit project_board_path(project, board)
wait_for_requests
click_card(card)
wait_for_requests
page.within('.subscriptions') do
click_button 'Unsubscribe'
wait_for_requests
expect(page).to have_content('Subscribe')
end end
end end
end end

View File

@ -9,10 +9,11 @@
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/models/assignee'; import '~/boards/models/assignee';
import eventHub from '~/boards/eventhub';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/label'; import '~/boards/models/label';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card'; import boardCard from '~/boards/components/board_card.vue';
import './mock_data'; import './mock_data';
describe('Board card', () => { describe('Board card', () => {
@ -157,33 +158,35 @@ describe('Board card', () => {
}); });
it('sets detail issue to card issue on mouse up', () => { it('sets detail issue to card issue on mouse up', () => {
spyOn(eventHub, '$emit');
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(gl.issueBoards.BoardsStore.detail.issue).toEqual(vm.issue); expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', vm.issue);
expect(gl.issueBoards.BoardsStore.detail.list).toEqual(vm.list); expect(gl.issueBoards.BoardsStore.detail.list).toEqual(vm.list);
}); });
it('adds active class if detail issue is set', (done) => { it('adds active class if detail issue is set', (done) => {
triggerEvent('mousedown'); vm.detailIssue.issue = vm.issue;
triggerEvent('mouseup');
setTimeout(() => { Vue.nextTick()
expect(vm.$el.classList.contains('is-active')).toBe(true); .then(() => {
done(); expect(vm.$el.classList.contains('is-active')).toBe(true);
}, 0); })
.then(done)
.catch(done.fail);
}); });
it('resets detail issue to empty if already set', () => { it('resets detail issue to empty if already set', () => {
triggerEvent('mousedown'); spyOn(eventHub, '$emit');
triggerEvent('mouseup');
expect(gl.issueBoards.BoardsStore.detail.issue).toEqual(vm.issue); gl.issueBoards.BoardsStore.detail.issue = vm.issue;
triggerEvent('mousedown'); triggerEvent('mousedown');
triggerEvent('mouseup'); triggerEvent('mouseup');
expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue');
}); });
}); });
}); });

View File

@ -133,6 +133,19 @@ describe('Issue model', () => {
expect(relativePositionIssue.position).toBe(1); expect(relativePositionIssue.position).toBe(1);
}); });
it('updates data', () => {
issue.updateData({ subscribed: true });
expect(issue.subscribed).toBe(true);
});
it('sets fetching state', () => {
expect(issue.isFetching.subscriptions).toBe(true);
issue.setFetchingState('subscriptions', false);
expect(issue.isFetching.subscriptions).toBe(false);
});
describe('update', () => { describe('update', () => {
it('passes assignee ids when there are assignees', (done) => { it('passes assignee ids when there are assignees', (done) => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => { spyOn(Vue.http, 'patch').and.callFake((url, data) => {

View File

@ -98,7 +98,6 @@ describe('LoadingButton', function () {
it('does not call given callback when disabled because of loading', () => { it('does not call given callback when disabled because of loading', () => {
vm = mountComponent(LoadingButton, { vm = mountComponent(LoadingButton, {
loading: true, loading: true,
indeterminate: true,
}); });
spyOn(vm, '$emit'); spyOn(vm, '$emit');