Commit c25cf77d authored by Jacob Schatz's avatar Jacob Schatz
Browse files

Merge branch 'issue-title-vue-2' into 'master'

Issue title realtime

Closes #25051

See merge request !10115
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() {
......@@ -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);
};
......@@ -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]
......@@ -200,6 +200,11 @@ def can_create_branch
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
......
......@@ -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
......@@ -252,4 +254,13 @@ def readable_by?(user)
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
......@@ -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
......@@ -77,3 +78,5 @@
= render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable: @issue
= page_specific_javascript_bundle_tag('issue_show')
......@@ -250,6 +250,7 @@
get :referenced_merge_requests
get :related_branches
get :can_create_branch
get :rendered_title
end
collection do
post :bulk_update
......
......@@ -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: {
......
......@@ -3,7 +3,8 @@ module EtagCaching
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)
......
......@@ -48,7 +48,9 @@
end
end
describe "for issues" do
describe "for issues", feature: true, js: true do
include WaitForVueResource
before do
@other_issue = create(:issue,
author: @user,
......@@ -79,6 +81,14 @@
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
......
......@@ -2,6 +2,7 @@
describe 'Awards Emoji', feature: true do
include WaitForAjax
include WaitForVueResource
let!(:project) { create(:project, :public) }
let!(:user) { create(:user) }
......@@ -22,10 +23,11 @@
# 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
......@@ -35,6 +37,7 @@
before do
visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end
it 'increments the thumbsdown emoji', js: true do
......
......@@ -37,8 +37,8 @@
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)
......
require 'rails_helper'
describe 'New issue', feature: true do
describe 'New issue', feature: true, js: true do
include StubENV
let(:project) { create(:project, :public) }
......
......@@ -695,4 +695,21 @@
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');
});
});
......@@ -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 () {
......
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