Issue title realtime
This commit is contained in:
parent
4e3de96ed0
commit
1c783007e6
|
@ -0,0 +1,26 @@
|
|||
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()))();
|
|
@ -0,0 +1,78 @@
|
|||
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>
|
||||
`,
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
export default class Service {
|
||||
constructor(resource, endpoint) {
|
||||
this.resource = resource;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.resource.get(this.endpoint);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/* 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,29 +1,9 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
export default (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('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 = {}));
|
||||
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 @@ 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
|
||||
|
|
|
@ -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 @@ 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
|
||||
|
|
|
@ -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 @@ constraints(ProjectUrlConstrainer.new) do
|
|||
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 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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 @@ 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
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
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 () {
|
||||
|
|
Loading…
Reference in New Issue