Commit 24040015 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'issue-title-description-realtime' into 'master'

Render Description Realtime :tada:

Closes #25049 and #31355

See merge request !10865
parents ecaa68a7 8985ea1b
No related merge requests found
export default (newStateData, tasks) => {
const $tasks = $('#task_status');
const $tasksShort = $('#task_status_short');
const $issueableHeader = $('.issuable-header');
const tasksStates = { newState: null, currentState: null };
if ($tasks.length === 0) {
if (!(newStateData.task_status.indexOf('0 of 0') === 0)) {
$issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`);
} else {
$issueableHeader.append('<span id="task_status"></span>');
}
} else {
tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0;
tasksStates.currentState = tasks.indexOf('0 of 0') === 0;
}
if ($tasks.length !== 0 && !tasksStates.newState) {
$tasks.text(newStateData.task_status);
$tasksShort.text(newStateData.task_status);
} else if (tasksStates.currentState) {
$issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`);
} else if (tasksStates.newState) {
$tasks.remove();
$tasksShort.remove();
}
};
import Vue from 'vue'; import Vue from 'vue';
import IssueTitle from './issue_title.vue'; import IssueTitle from './issue_title_description.vue';
import '../vue_shared/vue_resource_interceptor'; import '../vue_shared/vue_resource_interceptor';
(() => { (() => {
const issueTitleData = document.querySelector('.issue-title-data').dataset; const issueTitleData = document.querySelector('.issue-title-data').dataset;
const { initialTitle, endpoint } = issueTitleData; const { canUpdateTasksClass, endpoint } = issueTitleData;
const vm = new Vue({ const vm = new Vue({
el: '.issue-title-entrypoint', el: '.issue-title-entrypoint',
render: createElement => createElement(IssueTitle, { render: createElement => createElement(IssueTitle, {
props: { props: {
initialTitle, canUpdateTasksClass,
endpoint, endpoint,
}, },
}), }),
......
<script>
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: {
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() {
if (!Visibility.hidden()) {
this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
};
</script>
<template>
<h2 class="title" v-html="title"></h2>
</template>
<script>
import Visibility from 'visibilityjs';
import Poll from './../lib/utils/poll';
import Service from './services/index';
import tasks from './actions/tasks';
export default {
props: {
endpoint: {
required: true,
type: String,
},
canUpdateTasksClass: {
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) => {
throw new Error(err);
},
});
return {
poll,
apiData: {},
tasks: '0 of 0',
title: null,
titleText: '',
titleFlag: {
pre: true,
pulse: false,
},
description: null,
descriptionText: '',
descriptionChange: false,
descriptionFlag: {
pre: true,
pulse: false,
},
timeAgoEl: $('.issue_edited_ago'),
titleEl: document.querySelector('title'),
};
},
methods: {
updateFlag(key, toggle) {
this[key].pre = toggle;
this[key].pulse = !toggle;
},
renderResponse(res) {
this.apiData = res.json();
this.triggerAnimation();
},
updateTaskHTML() {
tasks(this.apiData, this.tasks);
},
elementsToVisualize(noTitleChange, noDescriptionChange) {
if (!noTitleChange) {
this.titleText = this.apiData.title_text;
this.updateFlag('titleFlag', true);
}
if (!noDescriptionChange) {
// only change to true when we need to bind TaskLists the html of description
this.descriptionChange = true;
this.updateTaskHTML();
this.tasks = this.apiData.task_status;
this.updateFlag('descriptionFlag', true);
}
},
setTabTitle() {
const currentTabTitleScope = this.titleEl.innerText.split('·');
currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `;
this.titleEl.innerText = currentTabTitleScope.join('·');
},
animate(title, description) {
this.title = title;
this.description = description;
this.setTabTitle();
this.$nextTick(() => {
this.updateFlag('titleFlag', false);
this.updateFlag('descriptionFlag', false);
});
},
triggerAnimation() {
// always reset to false before checking the change
this.descriptionChange = false;
const { title, description } = this.apiData;
this.descriptionText = this.apiData.description_text;
const noTitleChange = this.title === title;
const noDescriptionChange = this.description === description;
/**
* since opacity is changed, even if there is no diff for Vue to update
* we must check the title/description even on a 304 to ensure no visual change
*/
if (noTitleChange && noDescriptionChange) return;
this.elementsToVisualize(noTitleChange, noDescriptionChange);
this.animate(title, description);
},
updateEditedTimeAgo() {
const toolTipTime = gl.utils.formatDate(this.apiData.updated_at);
this.timeAgoEl.attr('datetime', this.apiData.updated_at);
this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle');
},
},
created() {
if (!Visibility.hidden()) {
this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
updated() {
// if new html is injected (description changed) - bind TaskList and call renderGFM
if (this.descriptionChange) {
this.updateEditedTimeAgo();
$(this.$refs['issue-content-container-gfm-entry']).renderGFM();
const tl = new gl.TaskList({
dataType: 'issue',
fieldName: 'description',
selector: '.detail-page-description',
});
return tl && null;
}
return null;
},
};
</script>
<template>
<div>
<h2
class="title"
:class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }"
ref="issue-title"
v-html="title"
>
</h2>
<div
class="description is-task-list-enabled"
:class="canUpdateTasksClass"
v-if="description"
>
<div
class="wiki"
:class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }"
v-html="description"
ref="issue-content-container-gfm-entry"
>
</div>
<textarea
class="hidden js-task-list-field"
v-if="descriptionText"
>{{descriptionText}}</textarea>
</div>
</div>
</template>
...@@ -18,6 +18,15 @@ ...@@ -18,6 +18,15 @@
} }
} }
.issue-realtime-pre-pulse {
opacity: 0;
}
.issue-realtime-trigger-pulse {
transition: opacity $fade-in-duration linear;
opacity: 1;
}
.check-all-holder { .check-all-holder {
line-height: 36px; line-height: 36px;
float: left; float: left;
......
...@@ -201,7 +201,16 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -201,7 +201,16 @@ class Projects::IssuesController < Projects::ApplicationController
def rendered_title def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000) Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { title: view_context.markdown_field(@issue, :title) }
render json: {
title: view_context.markdown_field(@issue, :title),
title_text: @issue.title,
description: view_context.markdown_field(@issue, :description),
description_text: @issue.description,
task_status: @issue.task_status,
issue_number: @issue.iid,
updated_at: @issue.updated_at,
}
end end
def create_merge_request def create_merge_request
......
...@@ -51,16 +51,11 @@ ...@@ -51,16 +51,11 @@
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block .detail-page-description.content-block
.issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title), .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
"endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '',
} } } }
.issue-title-entrypoint .issue-title-entrypoint
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
.wiki
= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field
= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago')
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
......
---
title: Add realtime descriptions to issue show pages
merge_request:
author:
...@@ -82,6 +82,7 @@ Feature: Project Issues ...@@ -82,6 +82,7 @@ Feature: Project Issues
# Markdown # Markdown
@javascript
Scenario: Headers inside the description should have ids generated for them. Scenario: Headers inside the description should have ids generated for them.
Given I visit issue page "Release 0.4" Given I visit issue page "Release 0.4"
Then Header "Description header" should have correct id and link Then Header "Description header" should have correct id and link
......
...@@ -6,9 +6,12 @@ feature 'Issue awards', js: true, feature: true do ...@@ -6,9 +6,12 @@ feature 'Issue awards', js: true, feature: true do
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
describe 'logged in' do describe 'logged in' do
include WaitForVueResource
before do before do
login_as(user) login_as(user)
visit namespace_project_issue_path(project.namespace, project, issue) visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end end
it 'adds award to issue' do it 'adds award to issue' do
...@@ -38,8 +41,11 @@ feature 'Issue awards', js: true, feature: true do ...@@ -38,8 +41,11 @@ feature 'Issue awards', js: true, feature: true do
end end
describe 'logged out' do describe 'logged out' do
include WaitForVueResource
before do before do
visit namespace_project_issue_path(project.namespace, project, issue) visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end end
it 'does not see award menu button' do it 'does not see award menu button' do
......
...@@ -62,12 +62,15 @@ feature 'Task Lists', feature: true do ...@@ -62,12 +62,15 @@ feature 'Task Lists', feature: true do
visit namespace_project_issue_path(project.namespace, project, issue) visit namespace_project_issue_path(project.namespace, project, issue)
end end
describe 'for Issues' do describe 'for Issues', feature: true do
describe 'multiple tasks' do describe 'multiple tasks', js: true do
include WaitForVueResource
let!(:issue) { create(:issue, description: markdown, author: user, project: project) } let!(:issue) { create(:issue, description: markdown, author: user, project: project) }
it 'renders' do it 'renders' do
visit_issue(project, issue) visit_issue(project, issue)
wait_for_vue_resource
expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 6) expect(page).to have_selector('li.task-list-item', count: 6)
...@@ -76,25 +79,24 @@ feature 'Task Lists', feature: true do ...@@ -76,25 +79,24 @@ feature 'Task Lists', feature: true do
it 'contains the required selectors' do it 'contains the required selectors' do
visit_issue(project, issue) visit_issue(project, issue)
wait_for_vue_resource
container = '.detail-page-description .description.js-task-list-container' expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
expect(page).to have_selector(container)
expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox")
expect(page).to have_selector("#{container} .js-task-list-field")
expect(page).to have_selector('form.js-issuable-update')
expect(page).to have_selector('a.btn-close') expect(page).to have_selector('a.btn-close')
end end
it 'is only editable by author' do it 'is only editable by author' do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container') wait_for_vue_resource
logout(:user) expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
logout(:user)
login_as(user2) login_as(user2)
visit current_path visit current_path
expect(page).not_to have_selector('.js-task-list-container') wait_for_vue_resource
expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox")
end end
it 'provides a summary on Issues#index' do it 'provides a summary on Issues#index' do
...@@ -103,11 +105,14 @@ feature 'Task Lists', feature: true do ...@@ -103,11 +105,14 @@ feature 'Task Lists', feature: true do
end end
end end
describe 'single incomplete task' do describe 'single incomplete task', js: true do
include WaitForVueResource
let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) }
it 'renders' do it 'renders' do
visit_issue(project, issue) visit_issue(project, issue)
wait_for_vue_resource
expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1) expect(page).to have_selector('li.task-list-item', count: 1)
...@@ -116,15 +121,18 @@ feature 'Task Lists', feature: true do ...@@ -116,15 +121,18 @@ feature 'Task Lists', feature: true do
it 'provides a summary on Issues#index' do it 'provides a summary on Issues#index' do
visit namespace_project_issues_path(project.namespace, project) visit namespace_project_issues_path(project.namespace, project)
expect(page).to have_content("0 of 1 task completed") expect(page).to have_content("0 of 1 task completed")
end end
end end
describe 'single complete task' do describe 'single complete task', js: true do
include WaitForVueResource
let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) }
it 'renders' do it 'renders' do
visit_issue(project, issue) visit_issue(project, issue)
wait_for_vue_resource
expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1) expect(page).to have_selector('li.task-list-item', count: 1)
...@@ -133,6 +141,7 @@ feature 'Task Lists', feature: true do ...@@ -133,6 +141,7 @@ feature 'Task Lists', feature: true do
it 'provides a summary on Issues#index' do it 'provides a summary on Issues#index' do
visit namespace_project_issues_path(project.namespace, project) visit namespace_project_issues_path(project.namespace, project)
expect(page).to have_content("1 of 1 task completed") expect(page).to have_content("1 of 1 task completed")
end end
end end
......
import Vue from 'vue';
import $ from 'jquery';
import '~/render_math';
import '~/render_gfm';
import issueTitleDescription from '~/issue_show/issue_title_description.vue';
import issueShowData from './mock_data';
window.$ = $;
const issueShowInterceptor = data => (request, next) => {
next(request.respondWith(JSON.stringify(data), {
status: 200,
headers: {
'POLL-INTERVAL': 1,
},
}));
};
describe('Issue Title', () => {
document.body.innerHTML = '<span id="task_status"></span>';
let IssueTitleDescriptionComponent;
beforeEach(() => {
IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor);
});
it('should render a title/description and update title/description on update', (done) => {
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
const issueShowComponent = new IssueTitleDescriptionComponent({
propsData: {
canUpdateIssue: '.css-stuff',
endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title',
},
}).$mount();
setTimeout(() => {
expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description');
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
setTimeout(() => {
expect(document.querySelector('title').innerText).toContain('2 (#1)');
expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42');
done();
});
});
});
});
import Vue from 'vue';
import issueTitle from '~/issue_show/issue_title.vue';
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');
});
});
export default {
initialRequest: {
title: '<p>this is a title</p>',
title_text: 'this is a title',
description: '<p>this is a description!</p>',
description_text: 'this is a description',
issue_number: 1,
task_status: '2 of 4 completed',
},
secondRequest: {
title: '<p>2</p>',
title_text: '2',
description: '<p>42</p>',
description_text: '42',
issue_number: 1,
task_status: '0 of 0 completed',
},
issueSpecRequest: {
title: '<p>this is a title</p>',
title_text: 'this is a title',
description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>',
description_text: '- [ ] Task List Item',
issue_number: 1,
task_status: '0 of 1 completed',
},
};
...@@ -81,12 +81,6 @@ describe('Issue', function() { ...@@ -81,12 +81,6 @@ describe('Issue', function() {
this.issue = new Issue(); this.issue = new Issue();
}); });
it('modifies the Markdown field', function() {
spyOn(jQuery, 'ajax').and.stub();
$('input[type=checkbox]').attr('checked', true).trigger('change');
expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
});
it('submits an ajax request on tasklist:changed', function() { it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) { spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH'); expect(req.type).toBe('PATCH');
......
...@@ -25,7 +25,7 @@ shared_examples 'issuable record that supports slash commands in its description ...@@ -25,7 +25,7 @@ shared_examples 'issuable record that supports slash commands in its description
wait_for_ajax wait_for_ajax
end end
describe "new #{issuable_type}" do describe "new #{issuable_type}", js: true do
context 'with commands in the description' do context 'with commands in the description' do
it "creates the #{issuable_type} and interpret commands accordingly" do it "creates the #{issuable_type} and interpret commands accordingly" do
visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts)
...@@ -44,7 +44,7 @@ shared_examples 'issuable record that supports slash commands in its description ...@@ -44,7 +44,7 @@ shared_examples 'issuable record that supports slash commands in its description
end end
end end
describe "note on #{issuable_type}" do describe "note on #{issuable_type}", js: true do
before do before do
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end end
......
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