summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/vue_issue_show/index.js35
-rw-r--r--app/assets/javascripts/vue_issue_show/issue_title.js46
-rw-r--r--app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js4
-rw-r--r--app/assets/javascripts/vue_poller/index.js60
-rw-r--r--app/assets/javascripts/vue_realtime_listener/index.js36
-rw-r--r--app/controllers/projects/issues_controller.rb8
-rw-r--r--app/models/issue.rb11
-rw-r--r--app/views/projects/issues/show.html.haml11
-rw-r--r--config/routes/project.rb1
-rw-r--r--config/webpack.config.js1
-rw-r--r--lib/gitlab/etag_caching/middleware.rb5
-rw-r--r--spec/features/gitlab_flavored_markdown_spec.rb12
-rw-r--r--spec/features/issues/move_spec.rb4
-rw-r--r--spec/features/issues_spec.rb17
14 files changed, 213 insertions, 38 deletions
diff --git a/app/assets/javascripts/vue_issue_show/index.js b/app/assets/javascripts/vue_issue_show/index.js
new file mode 100644
index 00000000000..07087cb27aa
--- /dev/null
+++ b/app/assets/javascripts/vue_issue_show/index.js
@@ -0,0 +1,35 @@
+const Vue = require('vue');
+require('../vue_shared/vue_resource_interceptor');
+const IssueTitle = require('./issue_title');
+
+const token = document.querySelector('meta[name="csrf-token"]');
+if (token) Vue.http.headers.common['X-CSRF-token'] = token.content;
+
+const vueData = document.querySelector('.vue-data').dataset;
+
+const vueOptions = () => ({
+ el: '.issue-title-vue',
+ components: {
+ IssueTitle,
+ },
+ data() {
+ return {
+ initialTitle: vueData.initialTitle,
+ endpoint: vueData.endpoint,
+ };
+ },
+ template: `
+ <div>
+ <IssueTitle
+ :initialTitle='initialTitle'
+ :endpoint='endpoint'
+ />
+ </div>
+ `,
+});
+
+Vue.activeResources = 0;
+
+const vm = new Vue(vueOptions());
+
+(() => vm)();
diff --git a/app/assets/javascripts/vue_issue_show/issue_title.js b/app/assets/javascripts/vue_issue_show/issue_title.js
new file mode 100644
index 00000000000..001819c65ed
--- /dev/null
+++ b/app/assets/javascripts/vue_issue_show/issue_title.js
@@ -0,0 +1,46 @@
+const VuePoller = require('../vue_poller/index');
+
+module.exports = {
+ props: {
+ initialTitle: { required: true, type: String },
+ endpoint: { required: true, type: String },
+ },
+ data() {
+ return {
+ intervalId: '',
+ title: this.initialTitle,
+ };
+ },
+ created() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ return new VuePoller({
+ url: this.endpoint,
+ time: 3000,
+ success: (res) => { this.renderResponse(res); },
+ error: (err) => { throw Error(err); },
+ }).run();
+ },
+ renderResponse(res) {
+ const body = JSON.parse(res.body);
+ this.triggerAnimation(body);
+ },
+ triggerAnimation(body) {
+ const { title } = body;
+ if (this.title === title) return;
+
+ this.$el.style.opacity = 0;
+
+ setTimeout(() => {
+ this.title = title;
+ this.$el.style.transition = 'opacity 0.2s ease';
+ this.$el.style.opacity = 1;
+ }, 100);
+ },
+ },
+ template: `
+ <h2 class='title' v-html='title'></h2>
+ `,
+};
diff --git a/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js
index 7ac10086a55..6f19e46c7dc 100644
--- a/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js
+++ b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js
@@ -1,5 +1,7 @@
/* eslint-disable no-underscore-dangle*/
-import '../../vue_realtime_listener';
+import VueRealtimeListener from '../../vue_realtime_listener';
+
+gl.VueRealtimeListener = VueRealtimeListener;
export default class PipelinesStore {
constructor() {
diff --git a/app/assets/javascripts/vue_poller/index.js b/app/assets/javascripts/vue_poller/index.js
new file mode 100644
index 00000000000..063bcd84638
--- /dev/null
+++ b/app/assets/javascripts/vue_poller/index.js
@@ -0,0 +1,60 @@
+const Vue = require('vue');
+const VueResource = require('vue-resource');
+const VueRealtimeListener = require('../vue_realtime_listener/index');
+
+class VueShortPoller {
+ constructor(options) {
+ Vue.use(VueResource);
+
+ this.options = options;
+ this.state = {
+ pollId: null,
+ polling: false,
+ };
+
+ this.poll = this.poll.bind(this);
+ this.removePoll = this.removePoll.bind(this);
+ this.run = this.run.bind(this);
+ }
+
+ poll() {
+ const { time } = this.options;
+ this.state.pollId = setInterval(() => {
+ const { polling } = this.state;
+ return polling ? null : this.fetchData(this.options);
+ }, time);
+ }
+
+ removePoll() {
+ clearInterval(this.state.pollId);
+ }
+
+ fetchData({ url, data = {}, success, error }) {
+ const lastFetchedAt = new Date();
+
+ Object.assign(data, {
+ headers: { 'X-Last-Fetched-At': lastFetchedAt },
+ });
+
+ this.state.polling = true;
+
+ Vue.http.get(url, data)
+ .then((res) => {
+ success(res);
+ })
+ .then(() => {
+ this.state.polling = false;
+ })
+ .catch((err) => {
+ this.removePoll();
+ error(err);
+ });
+ }
+
+ run() {
+ this.poll();
+ VueRealtimeListener(this.removePoll, this.poll);
+ }
+}
+
+module.exports = VueShortPoller;
diff --git a/app/assets/javascripts/vue_realtime_listener/index.js b/app/assets/javascripts/vue_realtime_listener/index.js
index 30f6680a673..711f515c322 100644
--- a/app/assets/javascripts/vue_realtime_listener/index.js
+++ b/app/assets/javascripts/vue_realtime_listener/index.js
@@ -1,29 +1,11 @@
-/* eslint-disable no-param-reassign */
+const VueRealtimeListener = (removeIntervals, startIntervals) => {
+ window.removeEventListener('focus', startIntervals);
+ window.removeEventListener('blur', removeIntervals);
+ window.removeEventListener('onbeforeload', removeIntervals);
-((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('focus', startIntervals);
+ window.addEventListener('blur', removeIntervals);
+ window.addEventListener('onbeforeload', removeIntervals);
+};
- 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 = {}));
+module.exports = VueRealtimeListener;
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index cdb5b4173d3..7f81280673b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -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]
@@ -193,6 +193,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
+ def rendered_title
+ render json: { title: view_context.markdown_field(@issue, :title) }
+ end
+
protected
def issue
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 602eed86d9e..c2b261825b2 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -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
@@ -257,4 +259,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
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 6ac05bf3afe..d665c9125b3 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -49,11 +49,14 @@
= 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)
+ .vue-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
+ "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
+ "user" => "#{current_user.nil?}",
+ "initial-title-digest" => hexdigest(@issue.title),
+ } }
+ .issue-title-vue
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
.wiki
@@ -77,3 +80,5 @@
= render 'projects/issues/discussion'
= render 'shared/issuable/sidebar', issuable: @issue
+
+= page_specific_javascript_bundle_tag('vue_issue_show')
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 44b8ae7aedd..df2b5ebb408 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -248,6 +248,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
diff --git a/config/webpack.config.js b/config/webpack.config.js
index c6794d6b944..9753828fe5a 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -44,6 +44,7 @@ var config = {
u2f: ['vendor/u2f'],
users: './users/users_bundle.js',
vue_pipelines: './vue_pipelines_index/index.js',
+ vue_issue_show: './vue_issue_show/index.js',
},
output: {
diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb
index ffbc6e17dc5..5671fa21219 100644
--- a/lib/gitlab/etag_caching/middleware.rb
+++ b/lib/gitlab/etag_caching/middleware.rb
@@ -1,9 +1,10 @@
module Gitlab
module EtagCaching
class Middleware
- RESERVED_WORDS = ProjectPathValidator::RESERVED.map { |word| "/#{word}/" }.join('|')
+ 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)
diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb
index 84d73d693bc..876f33dd03e 100644
--- a/spec/features/gitlab_flavored_markdown_spec.rb
+++ b/spec/features/gitlab_flavored_markdown_spec.rb
@@ -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,
@@ -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
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index f89b4db9e62..6c09903a2f6 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -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)
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index a58aedc924e..6f831ea4b67 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -678,4 +678,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_issues_path(project.namespace, project, assignee_id: @user.id)
+
+ 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