diff options
-rw-r--r-- | app/assets/javascripts/vue_issue_show/index.js | 35 | ||||
-rw-r--r-- | app/assets/javascripts/vue_issue_show/issue_title.js | 46 | ||||
-rw-r--r-- | app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/vue_poller/index.js | 60 | ||||
-rw-r--r-- | app/assets/javascripts/vue_realtime_listener/index.js | 36 | ||||
-rw-r--r-- | app/controllers/projects/issues_controller.rb | 8 | ||||
-rw-r--r-- | app/models/issue.rb | 11 | ||||
-rw-r--r-- | app/views/projects/issues/show.html.haml | 11 | ||||
-rw-r--r-- | config/routes/project.rb | 1 | ||||
-rw-r--r-- | config/webpack.config.js | 1 | ||||
-rw-r--r-- | lib/gitlab/etag_caching/middleware.rb | 5 | ||||
-rw-r--r-- | spec/features/gitlab_flavored_markdown_spec.rb | 12 | ||||
-rw-r--r-- | spec/features/issues/move_spec.rb | 4 | ||||
-rw-r--r-- | spec/features/issues_spec.rb | 17 |
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 |