Skip to content
Snippets Groups Projects
Commit 24040015 authored by Phil Hughes's avatar Phil Hughes
Browse files

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 branches found
No related tags found
No related merge requests found
Showing
with 353 additions and 135 deletions
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 IssueTitle from './issue_title.vue';
import IssueTitle from './issue_title_description.vue';
import '../vue_shared/vue_resource_interceptor';
 
(() => {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
const { initialTitle, endpoint } = issueTitleData;
const { canUpdateTasksClass, endpoint } = issueTitleData;
 
const vm = new Vue({
el: '.issue-title-entrypoint',
render: createElement => createElement(IssueTitle, {
props: {
initialTitle,
canUpdateTasksClass,
endpoint,
},
}),
Loading
Loading
<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>
Loading
Loading
@@ -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 {
line-height: 36px;
float: left;
Loading
Loading
Loading
Loading
@@ -201,7 +201,16 @@ class Projects::IssuesController < Projects::ApplicationController
 
def rendered_title
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
 
def create_merge_request
Loading
Loading
Loading
Loading
@@ -51,16 +51,11 @@
 
.issue-details.issuable-details
.detail-page-description.content-block
.issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title),
"endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
.issue-title-data.hidden{ "data" => { "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
- 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')
 
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
Loading
Loading
---
title: Add realtime descriptions to issue show pages
merge_request:
author:
Loading
Loading
@@ -82,6 +82,7 @@ Feature: Project Issues
 
# Markdown
 
@javascript
Scenario: Headers inside the description should have ids generated for them.
Given I visit issue page "Release 0.4"
Then Header "Description header" should have correct id and link
Loading
Loading
Loading
Loading
@@ -6,9 +6,12 @@ feature 'Issue awards', js: true, feature: true do
let(:issue) { create(:issue, project: project) }
 
describe 'logged in' do
include WaitForVueResource
before do
login_as(user)
visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end
 
it 'adds award to issue' do
Loading
Loading
@@ -38,8 +41,11 @@ feature 'Issue awards', js: true, feature: true do
end
 
describe 'logged out' do
include WaitForVueResource
before do
visit namespace_project_issue_path(project.namespace, project, issue)
wait_for_vue_resource
end
 
it 'does not see award menu button' do
Loading
Loading
Loading
Loading
@@ -62,12 +62,15 @@ feature 'Task Lists', feature: true do
visit namespace_project_issue_path(project.namespace, project, issue)
end
 
describe 'for Issues' do
describe 'multiple tasks' do
describe 'for Issues', feature: true do
describe 'multiple tasks', js: true do
include WaitForVueResource
let!(:issue) { create(:issue, description: markdown, author: user, project: project) }
 
it 'renders' do
visit_issue(project, issue)
wait_for_vue_resource
 
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 6)
Loading
Loading
@@ -76,25 +79,24 @@ feature 'Task Lists', feature: true do
 
it 'contains the required selectors' do
visit_issue(project, issue)
wait_for_vue_resource
 
container = '.detail-page-description .description.js-task-list-container'
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(".wiki .task-list .task-list-item .task-list-item-checkbox")
expect(page).to have_selector('a.btn-close')
end
 
it 'is only editable by author' do
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)
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
 
it 'provides a summary on Issues#index' do
Loading
Loading
@@ -103,11 +105,14 @@ feature 'Task Lists', feature: true do
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) }
 
it 'renders' do
visit_issue(project, issue)
wait_for_vue_resource
 
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1)
Loading
Loading
@@ -116,15 +121,18 @@ feature 'Task Lists', feature: true do
 
it 'provides a summary on Issues#index' do
visit namespace_project_issues_path(project.namespace, project)
expect(page).to have_content("0 of 1 task completed")
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) }
 
it 'renders' do
visit_issue(project, issue)
wait_for_vue_resource
 
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1)
Loading
Loading
@@ -133,6 +141,7 @@ feature 'Task Lists', feature: true do
 
it 'provides a summary on Issues#index' do
visit namespace_project_issues_path(project.namespace, project)
expect(page).to have_content("1 of 1 task completed")
end
end
Loading
Loading
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',
},
};
Loading
Loading
@@ -81,12 +81,6 @@ describe('Issue', function() {
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() {
spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH');
Loading
Loading
Loading
Loading
@@ -25,7 +25,7 @@ shared_examples 'issuable record that supports slash commands in its description
wait_for_ajax
end
 
describe "new #{issuable_type}" do
describe "new #{issuable_type}", js: true do
context 'with commands in the description' 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)
Loading
Loading
@@ -44,7 +44,7 @@ shared_examples 'issuable record that supports slash commands in its description
end
end
 
describe "note on #{issuable_type}" do
describe "note on #{issuable_type}", js: true do
before do
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment