Commit 1c783007 authored by Regis Boudinot's avatar Regis Boudinot Committed by Jacob Schatz

Issue title realtime

parent 4e3de96e
import Vue from 'vue';
import IssueTitle from './issue_title';
import '../vue_shared/vue_resource_interceptor';
const vueOptions = () => ({
el: '.issue-title-entrypoint',
components: {
IssueTitle,
},
data() {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
return {
initialTitle: issueTitleData.initialTitle,
endpoint: issueTitleData.endpoint,
};
},
template: `
<IssueTitle
:initialTitle="initialTitle"
:endpoint="endpoint"
/>
`,
});
(() => new Vue(vueOptions()))();
import Visibility from 'visibilityjs';
import Poll from './../lib/utils/poll';
import Service from './services/index';
export default {
props: {
initialTitle: { required: true, type: String },
endpoint: { required: true, type: String },
},
data() {
const resource = new Service(this.$http, this.endpoint);
const poll = new Poll({
resource,
method: 'getTitle',
successCallback: (res) => {
this.renderResponse(res);
},
errorCallback: (err) => {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error('ISSUE SHOW TITLE REALTIME ERROR', err);
} else {
throw new Error(err);
}
},
});
return {
poll,
timeoutId: null,
title: this.initialTitle,
};
},
methods: {
fetch() {
this.poll.makeRequest();
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
renderResponse(res) {
const body = JSON.parse(res.body);
this.triggerAnimation(body);
},
triggerAnimation(body) {
const { title } = body;
/**
* since opacity is changed, even if there is no diff for Vue to update
* we must check the title even on a 304 to ensure no visual change
*/
if (this.title === title) return;
this.$el.style.opacity = 0;
this.timeoutId = setTimeout(() => {
this.title = title;
this.$el.style.transition = 'opacity 0.2s ease';
this.$el.style.opacity = 1;
clearTimeout(this.timeoutId);
}, 100);
},
},
created() {
this.fetch();
},
template: `
<h2 class='title' v-html='title'></h2>
`,
};
export default class Service {
constructor(resource, endpoint) {
this.resource = resource;
this.endpoint = endpoint;
}
getTitle() {
return this.resource.get(this.endpoint);
}
}
/* eslint-disable no-underscore-dangle*/ /* eslint-disable no-underscore-dangle*/
import '../../vue_realtime_listener'; import VueRealtimeListener from '../../vue_realtime_listener';
export default class PipelinesStore { export default class PipelinesStore {
constructor() { constructor() {
...@@ -56,6 +56,6 @@ export default class PipelinesStore { ...@@ -56,6 +56,6 @@ export default class PipelinesStore {
const removeIntervals = () => clearInterval(this.timeLoopInterval); const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops(); const startIntervals = () => startTimeLoops();
gl.VueRealtimeListener(removeIntervals, startIntervals); VueRealtimeListener(removeIntervals, startIntervals);
} }
} }
/* eslint-disable no-param-reassign */ export default (removeIntervals, startIntervals) => {
((gl) => {
gl.VueRealtimeListener = (removeIntervals, startIntervals) => {
const removeAll = () => {
removeIntervals();
window.removeEventListener('beforeunload', removeIntervals);
window.removeEventListener('focus', startIntervals); window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals); window.removeEventListener('blur', removeIntervals);
document.removeEventListener('beforeunload', removeAll); window.removeEventListener('onbeforeload', removeIntervals);
};
window.addEventListener('beforeunload', removeIntervals);
window.addEventListener('focus', startIntervals); window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals); window.addEventListener('blur', removeIntervals);
document.addEventListener('beforeunload', removeAll); window.addEventListener('onbeforeload', removeIntervals);
};
// add removeAll methods to stack
const stack = gl.VueRealtimeListener.reset;
gl.VueRealtimeListener.reset = () => {
gl.VueRealtimeListener.reset = stack;
removeAll();
stack();
};
};
// remove all event listeners and intervals
gl.VueRealtimeListener.reset = () => undefined; // noop
})(window.gl || (window.gl = {}));
...@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
:related_branches, :can_create_branch] :related_branches, :can_create_branch, :rendered_title]
# Allow read any issue # Allow read any issue
before_action :authorize_read_issue!, only: [:show] before_action :authorize_read_issue!, only: [:show, :rendered_title]
# Allow write(create) issue # Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create] before_action :authorize_create_issue!, only: [:new, :create]
...@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController
end end
end end
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { title: view_context.markdown_field(@issue, :title) }
end
protected protected
def issue def issue
......
...@@ -40,6 +40,8 @@ class Issue < ActiveRecord::Base ...@@ -40,6 +40,8 @@ class Issue < ActiveRecord::Base
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
after_save :expire_etag_cache
attr_spammable :title, spam_title: true attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true attr_spammable :description, spam_description: true
...@@ -252,4 +254,13 @@ class Issue < ActiveRecord::Base ...@@ -252,4 +254,13 @@ class Issue < ActiveRecord::Base
def publicly_visible? def publicly_visible?
project.public? && !confidential? project.public? && !confidential?
end end
def expire_etag_cache
key = Gitlab::Routing.url_helpers.rendered_title_namespace_project_issue_path(
project.namespace,
project,
self
)
Gitlab::EtagCaching::Store.new.touch(key)
end
end end
...@@ -49,11 +49,12 @@ ...@@ -49,11 +49,12 @@
= link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) } .detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) }
%h2.title .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
= markdown_field(@issue, :title) "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
} }
.issue-title-entrypoint
- if @issue.description.present? - if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
.wiki .wiki
...@@ -77,3 +78,5 @@ ...@@ -77,3 +78,5 @@
= render 'projects/issues/discussion' = render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable: @issue = render 'shared/issuable/sidebar', issuable: @issue
= page_specific_javascript_bundle_tag('issue_show')
...@@ -250,6 +250,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -250,6 +250,7 @@ constraints(ProjectUrlConstrainer.new) do
get :referenced_merge_requests get :referenced_merge_requests
get :related_branches get :related_branches
get :can_create_branch get :can_create_branch
get :rendered_title
end end
collection do collection do
post :bulk_update post :bulk_update
......
...@@ -46,6 +46,7 @@ var config = { ...@@ -46,6 +46,7 @@ var config = {
u2f: ['vendor/u2f'], u2f: ['vendor/u2f'],
users: './users/users_bundle.js', users: './users/users_bundle.js',
vue_pipelines: './vue_pipelines_index/index.js', vue_pipelines: './vue_pipelines_index/index.js',
issue_show: './issue_show/index.js',
}, },
output: { output: {
......
...@@ -3,7 +3,8 @@ module Gitlab ...@@ -3,7 +3,8 @@ module Gitlab
class Middleware class Middleware
RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|') RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|')
ROUTE_REGEXP = Regexp.union( ROUTE_REGEXP = Regexp.union(
%r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z) %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z),
%r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z)
) )
def initialize(app) def initialize(app)
......
...@@ -48,7 +48,9 @@ describe "GitLab Flavored Markdown", feature: true do ...@@ -48,7 +48,9 @@ describe "GitLab Flavored Markdown", feature: true do
end end
end end
describe "for issues" do describe "for issues", feature: true, js: true do
include WaitForVueResource
before do before do
@other_issue = create(:issue, @other_issue = create(:issue,
author: @user, author: @user,
...@@ -79,6 +81,14 @@ describe "GitLab Flavored Markdown", feature: true do ...@@ -79,6 +81,14 @@ describe "GitLab Flavored Markdown", feature: true do
expect(page).to have_link(fred.to_reference) expect(page).to have_link(fred.to_reference)
end end
it "renders updated subject once edited somewhere else in issues#show" do
visit namespace_project_issue_path(project.namespace, project, @issue)
@issue.update(title: "fix #{@other_issue.to_reference} and update")
wait_for_vue_resource
expect(page).to have_text("fix #{@other_issue.to_reference} and update")
end
end end
describe "for merge requests" do describe "for merge requests" do
......
...@@ -2,6 +2,7 @@ require 'rails_helper' ...@@ -2,6 +2,7 @@ require 'rails_helper'
describe 'Awards Emoji', feature: true do describe 'Awards Emoji', feature: true do
include WaitForAjax include WaitForAjax
include WaitForVueResource
let!(:project) { create(:project, :public) } let!(:project) { create(:project, :public) }
let!(:user) { create(:user) } let!(:user) { create(:user) }
...@@ -22,10 +23,11 @@ describe 'Awards Emoji', feature: true do ...@@ -22,10 +23,11 @@ describe 'Awards Emoji', feature: true do
# The `heart_tip` emoji is not valid anymore so we need to skip validation # The `heart_tip` emoji is not valid anymore so we need to skip validation
issue.award_emoji.build(user: user, name: 'heart_tip').save!(validate: false) issue.award_emoji.build(user: user, name: 'heart_tip').save!(validate: false)
visit namespace_project_issue_path(project.namespace, project, issue) visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end end
# Regression test: https://gitlab.com/gitlab-org/gitlab-ce/issues/29529 # Regression test: https://gitlab.com/gitlab-org/gitlab-ce/issues/29529
it 'does not shows a 500 page' do it 'does not shows a 500 page', js: true do
expect(page).to have_text(issue.title) expect(page).to have_text(issue.title)
end end
end end
...@@ -35,6 +37,7 @@ describe 'Awards Emoji', feature: true do ...@@ -35,6 +37,7 @@ describe 'Awards Emoji', feature: true do
before do before do
visit namespace_project_issue_path(project.namespace, project, issue) visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end end
it 'increments the thumbsdown emoji', js: true do it 'increments the thumbsdown emoji', js: true do
......
...@@ -37,8 +37,8 @@ feature 'issue move to another project' do ...@@ -37,8 +37,8 @@ feature 'issue move to another project' do
edit_issue(issue) edit_issue(issue)
end end
scenario 'moving issue to another project' do scenario 'moving issue to another project', js: true do
first('#move_to_project_id', visible: false).set(new_project.id) find('#move_to_project_id', visible: false).set(new_project.id)
click_button('Save changes') click_button('Save changes')
expect(current_url).to include project_path(new_project) expect(current_url).to include project_path(new_project)
......
require 'rails_helper' require 'rails_helper'
describe 'New issue', feature: true do describe 'New issue', feature: true, js: true do
include StubENV include StubENV
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
...@@ -695,4 +695,21 @@ describe 'Issues', feature: true do ...@@ -695,4 +695,21 @@ describe 'Issues', feature: true do
end end
end end
end end
describe 'title issue#show', js: true do
include WaitForVueResource
it 'updates the title', js: true do
issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title')
visit namespace_project_issue_path(project.namespace, project, issue)
expect(page).to have_text("new title")
issue.update(title: "updated title")
wait_for_vue_resource
expect(page).to have_text("updated title")
end
end
end end
import Vue from 'vue';
import issueTitle from '~/issue_show/issue_title';
describe('Issue Title', () => {
let IssueTitleComponent;
beforeEach(() => {
IssueTitleComponent = Vue.extend(issueTitle);
});
it('should render a title', () => {
const component = new IssueTitleComponent({
propsData: {
initialTitle: 'wow',
endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title',
},
}).$mount();
expect(component.$el.classList).toContain('title');
expect(component.$el.innerHTML).toContain('wow');
});
});
...@@ -64,6 +64,7 @@ if (process.env.BABEL_ENV === 'coverage') { ...@@ -64,6 +64,7 @@ if (process.env.BABEL_ENV === 'coverage') {
'./snippet/snippet_bundle.js', './snippet/snippet_bundle.js',
'./terminal/terminal_bundle.js', './terminal/terminal_bundle.js',
'./users/users_bundle.js', './users/users_bundle.js',
'./issue_show/index.js',
]; ];
describe('Uncovered files', function () { describe('Uncovered files', function () {
......
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