Skip to content
Snippets Groups Projects
Commit 1c783007 authored by Regis Boudinot's avatar Regis Boudinot Committed by Jacob Schatz
Browse files

Issue title realtime

parent 4e3de96e
No related branches found
No related tags found
No related merge requests found
Showing
with 211 additions and 42 deletions
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*/
import '../../vue_realtime_listener';
import VueRealtimeListener from '../../vue_realtime_listener';
 
export default class PipelinesStore {
constructor() {
Loading
Loading
@@ -56,6 +56,6 @@ export default class PipelinesStore {
const removeIntervals = () => clearInterval(this.timeLoopInterval);
const startIntervals = () => startTimeLoops();
 
gl.VueRealtimeListener(removeIntervals, startIntervals);
VueRealtimeListener(removeIntervals, startIntervals);
}
}
/* eslint-disable no-param-reassign */
((gl) => {
gl.VueRealtimeListener = (removeIntervals, startIntervals) => {
const removeAll = () => {
removeIntervals();
window.removeEventListener('beforeunload', removeIntervals);
window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals);
document.removeEventListener('beforeunload', removeAll);
};
window.addEventListener('beforeunload', removeIntervals);
window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals);
document.addEventListener('beforeunload', removeAll);
// 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 = {}));
export default (removeIntervals, startIntervals) => {
window.removeEventListener('focus', startIntervals);
window.removeEventListener('blur', removeIntervals);
window.removeEventListener('onbeforeload', removeIntervals);
window.addEventListener('focus', startIntervals);
window.addEventListener('blur', removeIntervals);
window.addEventListener('onbeforeload', removeIntervals);
};
Loading
Loading
@@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
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
before_action :authorize_read_issue!, only: [:show]
before_action :authorize_read_issue!, only: [:show, :rendered_title]
 
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
Loading
Loading
@@ -200,6 +200,11 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
 
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { title: view_context.markdown_field(@issue, :title) }
end
protected
 
def issue
Loading
Loading
Loading
Loading
@@ -40,6 +40,8 @@ class Issue < ActiveRecord::Base
 
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
 
after_save :expire_etag_cache
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
 
Loading
Loading
@@ -252,4 +254,13 @@ class Issue < ActiveRecord::Base
def publicly_visible?
project.public? && !confidential?
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
Loading
Loading
@@ -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 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
 
.issue-details.issuable-details
.detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) }
%h2.title
= markdown_field(@issue, :title)
.issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
"endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
} }
.issue-title-entrypoint
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
.wiki
Loading
Loading
@@ -77,3 +78,5 @@
= render 'projects/issues/discussion'
 
= render 'shared/issuable/sidebar', issuable: @issue
= page_specific_javascript_bundle_tag('issue_show')
Loading
Loading
@@ -250,6 +250,7 @@ constraints(ProjectUrlConstrainer.new) do
get :referenced_merge_requests
get :related_branches
get :can_create_branch
get :rendered_title
end
collection do
post :bulk_update
Loading
Loading
Loading
Loading
@@ -46,6 +46,7 @@ var config = {
u2f: ['vendor/u2f'],
users: './users/users_bundle.js',
vue_pipelines: './vue_pipelines_index/index.js',
issue_show: './issue_show/index.js',
},
 
output: {
Loading
Loading
Loading
Loading
@@ -3,7 +3,8 @@ module Gitlab
class Middleware
RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|')
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)
Loading
Loading
Loading
Loading
@@ -48,7 +48,9 @@ describe "GitLab Flavored Markdown", feature: true do
end
end
 
describe "for issues" do
describe "for issues", feature: true, js: true do
include WaitForVueResource
before do
@other_issue = create(:issue,
author: @user,
Loading
Loading
@@ -79,6 +81,14 @@ describe "GitLab Flavored Markdown", feature: true do
 
expect(page).to have_link(fred.to_reference)
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
 
describe "for merge requests" do
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@ require 'rails_helper'
 
describe 'Awards Emoji', feature: true do
include WaitForAjax
include WaitForVueResource
 
let!(:project) { create(:project, :public) }
let!(:user) { create(:user) }
Loading
Loading
@@ -22,10 +23,11 @@ describe 'Awards Emoji', feature: true do
# 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)
visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end
 
# 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)
end
end
Loading
Loading
@@ -35,6 +37,7 @@ describe 'Awards Emoji', feature: true do
 
before do
visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end
 
it 'increments the thumbsdown emoji', js: true do
Loading
Loading
Loading
Loading
@@ -37,8 +37,8 @@ feature 'issue move to another project' do
edit_issue(issue)
end
 
scenario 'moving issue to another project' do
first('#move_to_project_id', visible: false).set(new_project.id)
scenario 'moving issue to another project', js: true do
find('#move_to_project_id', visible: false).set(new_project.id)
click_button('Save changes')
 
expect(current_url).to include project_path(new_project)
Loading
Loading
require 'rails_helper'
 
describe 'New issue', feature: true do
describe 'New issue', feature: true, js: true do
include StubENV
 
let(:project) { create(:project, :public) }
Loading
Loading
Loading
Loading
@@ -695,4 +695,21 @@ describe 'Issues', feature: true do
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
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');
});
});
Loading
Loading
@@ -64,6 +64,7 @@ if (process.env.BABEL_ENV === 'coverage') {
'./snippet/snippet_bundle.js',
'./terminal/terminal_bundle.js',
'./users/users_bundle.js',
'./issue_show/index.js',
];
 
describe('Uncovered files', function () {
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment