Read about our upcoming Code of Conduct on this issue

This instance will be upgraded to Heptapod 0.28.0 on 2022-01-18 at 14:00 UTC+1 (a few minutes of down time)

Commit aee6c059 authored by Sean McGivern's avatar Sean McGivern Committed by Oswaldo Ferreira
Browse files

Merge branch '39167-async-boards-sidebar' into 'master'

Make Issue Boards sidebar subscriptions async

Closes #39167 and #40094

See merge request gitlab-org/gitlab-ce!15364

(cherry picked from commit 7a61a8e09bcab5e0cb37ddb13900a5c07ef18918)

d2699aea Add issue sidebar and toggle_subscription endpoint in board issues data
f494dbc5 Async notification subscriptions in issue boards
parent 008937a9c8b0
/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */
/* global BoardService */
import _ from 'underscore';
import Vue from 'vue';
import VueResource from 'vue-resource';
import Flash from '../flash';
import { __ } from '../locale';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
import sidebarEventHub from '../sidebar/event_hub';
import './models/issue';
import './models/label';
import './models/list';
......@@ -14,7 +15,7 @@ import './models/milestone';
import './models/assignee';
import './stores/boards_store';
import './stores/modal_store';
import './services/board_service';
import BoardService from './services/board_service';
import './mixins/modal_mixins';
import './mixins/sortable_default_options';
import './filters/due_date_filters';
......@@ -77,11 +78,16 @@ $(() => {
});
Store.rootPath = this.boardsEndpoint;
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens);
eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
},
beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens);
eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
},
mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true);
......@@ -112,6 +118,46 @@ $(() => {
methods: {
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'));
});
}
}
},
});
......
<script>
import './issue_card_inner';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
export default {
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: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
......@@ -56,12 +42,30 @@ export default {
this.showDetail = false;
if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
Store.detail.issue = {};
eventHub.$emit('clearDetailIssue');
} else {
Store.detail.issue = this.issue;
eventHub.$emit('newDetailIssue', this.issue);
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>
/* global Sortable */
import boardNewIssue from './board_new_issue';
import boardCard from './board_card';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
......
......@@ -5,12 +5,13 @@
import Vue from 'vue';
import Flash from '../../flash';
import eventHub from '../../sidebar/event_hub';
import AssigneeTitle from '../../sidebar/components/assignees/assignee_title';
import Assignees from '../../sidebar/components/assignees/assignees';
import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
import assignees from '../../sidebar/components/assignees/assignees';
import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue';
import IssuableContext from '../../issuable_context';
import LabelsSelect from '../../labels_select';
import subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
const Store = gl.issueBoards.BoardsStore;
......@@ -117,11 +118,11 @@ gl.issueBoards.BoardSidebar = Vue.extend({
new DueDateSelectors();
new LabelsSelect();
new Sidebar();
gl.Subscription.bindAll('.subscription');
},
components: {
assigneeTitle,
assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn,
'assignee-title': AssigneeTitle,
assignees: Assignees,
subscriptions,
},
});
......@@ -17,6 +17,11 @@ class ListIssue {
this.assignees = [];
this.selected = false;
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) {
this.milestone = new ListMilestone(obj.milestone);
......@@ -73,6 +78,14 @@ class ListIssue {
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) {
const data = {
issue: {
......
......@@ -2,7 +2,7 @@
import Vue from 'vue';
class BoardService {
export default class BoardService {
constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: {
......@@ -88,6 +88,14 @@ class BoardService {
return this.issues.bulkUpdate(data);
}
static getIssueInfo(endpoint) {
return Vue.http.get(endpoint);
}
static toggleIssueSubscription(endpoint) {
return Vue.http.post(endpoint);
}
}
window.BoardService = BoardService;
......@@ -14,7 +14,6 @@ export default () => {
});
new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser);
gl.Subscription.bindAll('.subscription');
new DueDateSelectors();
window.sidebar = new Sidebar();
};
......@@ -88,7 +88,6 @@ import './right_sidebar';
import './search';
import './search_autocomplete';
import './smart_interval';
import './subscription';
import './subscription_select';
import initBreadcrumbs from './breadcrumb';
......
......@@ -3,6 +3,7 @@ import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
import Flash from '../../../flash';
import { __ } from '../../../locale';
import subscriptions from './subscriptions.vue';
export default {
......@@ -21,7 +22,7 @@ export default {
onToggleSubscription() {
this.mediator.toggleSubscription()
.catch(() => {
Flash('Error occurred when toggling the notification subscription');
Flash(__('Error occurred when toggling the notification subscription'));
});
},
},
......
......@@ -14,6 +14,10 @@ export default {
type: Boolean,
required: false,
},
id: {
type: Number,
required: false,
},
},
components: {
loadingButton,
......@@ -32,7 +36,7 @@ export default {
},
methods: {
toggleSubscription() {
eventHub.$emit('toggleSubscription');
eventHub.$emit('toggleSubscription', this.id);
},
},
};
......
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;
......@@ -84,6 +84,7 @@ def serialize_as_json(resource)
resource.as_json(
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
labels: true,
sidebar_endpoints: true,
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
......
......@@ -246,7 +246,12 @@ def check_for_spam?
def as_json(options = {})
super(options).tap do |json|
json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user]
if options.key?(:sidebar_endpoints) && project
url_helper = Gitlab::Routing.url_helpers
json.merge!(issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self))
end
if options.key?(:labels)
json[:labels] = labels.as_json(
......
- if current_user
.block.light.subscription{ ":data-url" => "'#{build_issue_link_base}/' + issue.iid + '/toggle_subscription'" }
%span.issuable-header-text.hide-collapsed.pull-left
Notifications
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
%span
{{issue.subscribed ? 'Unsubscribe' : 'Subscribe'}}
.block.subscriptions
%subscriptions{ ":loading" => "issue.isFetching && issue.isFetching.subscriptions",
":subscribed" => "issue.subscribed",
":id" => "issue.id" }
---
title: Update Issue Boards to fetch the notification subscription status asynchronously
merge_request:
author:
type: performance
......@@ -331,11 +331,29 @@
context 'subscription' do
it 'changes issue subscription' do
click_card(card)
wait_for_requests
page.within('.subscription') do
page.within('.subscriptions') do
click_button 'Subscribe'
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
......
......@@ -13,6 +13,8 @@
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" },
"issue_sidebar_endpoint": { "type": "string" },
"toggle_subscription_endpoint": { "type": "string" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
......
......@@ -9,10 +9,11 @@
import Vue from 'vue';
import '~/boards/models/assignee';
import eventHub from '~/boards/eventhub';
import '~/boards/models/list';
import '~/boards/models/label';
import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card';
import boardCard from '~/boards/components/board_card.vue';
import './mock_data';
describe('Board card', () => {
......@@ -157,33 +158,35 @@ describe('Board card', () => {
});
it('sets detail issue to card issue on mouse up', () => {
spyOn(eventHub, '$emit');
triggerEvent('mousedown');
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);
});
it('adds active class if detail issue is set', (done) => {
triggerEvent('mousedown');
triggerEvent('mouseup');
setTimeout(() => {
expect(vm.$el.classList.contains('is-active')).toBe(true);
done();
}, 0);
vm.detailIssue.issue = vm.issue;
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('is-active')).toBe(true);
})
.then(done)
.catch(done.fail);
});
it('resets detail issue to empty if already set', () => {
triggerEvent('mousedown');
triggerEvent('mouseup');
spyOn(eventHub, '$emit');
expect(gl.issueBoards.BoardsStore.detail.issue).toEqual(vm.issue);
gl.issueBoards.BoardsStore.detail.issue = vm.issue;
triggerEvent('mousedown');
triggerEvent('mouseup');
expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({});
expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue');
});
});
});
......@@ -133,6 +133,19 @@ describe('Issue model', () => {
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', () => {
it('passes assignee ids when there are assignees', (done) => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => {
......
......@@ -98,7 +98,6 @@ describe('LoadingButton', function () {
it('does not call given callback when disabled because of loading', () => {
vm = mountComponent(LoadingButton, {
loading: true,
indeterminate: true,
});
spyOn(vm, '$emit');
......
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