Merge branch 'refactor-realtime-issue' into 'master'

Refactored issue tealtime elements

See merge request !11242
This commit is contained in:
Filipa Lacerda 2017-05-12 08:42:48 +00:00
commit 20987f4fd2
17 changed files with 543 additions and 262 deletions

View File

@ -1,27 +0,0 @@
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();
}
};

View File

@ -0,0 +1,96 @@
<script>
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
export default {
props: {
endpoint: {
required: true,
type: String,
},
canUpdate: {
required: true,
type: Boolean,
},
issuableRef: {
type: String,
required: true,
},
initialTitle: {
type: String,
required: true,
},
initialDescriptionHtml: {
type: String,
required: false,
default: '',
},
initialDescriptionText: {
type: String,
required: false,
default: '',
},
},
data() {
const store = new Store({
titleHtml: this.initialTitle,
descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText,
});
return {
store,
state: store.state,
};
},
components: {
descriptionComponent,
titleComponent,
},
created() {
const resource = new Service(this.endpoint);
const poll = new Poll({
resource,
method: 'getData',
successCallback: (res) => {
this.store.updateState(res.json());
},
errorCallback(err) {
throw new Error(err);
},
});
if (!Visibility.hidden()) {
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
},
};
</script>
<template>
<div>
<title-component
:issuable-ref="issuableRef"
:title-html="state.titleHtml"
:title-text="state.titleText" />
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
</div>
</template>

View File

@ -0,0 +1,105 @@
<script>
import animateMixin from '../mixins/animate';
export default {
mixins: [animateMixin],
props: {
canUpdate: {
type: Boolean,
required: true,
},
descriptionHtml: {
type: String,
required: true,
},
descriptionText: {
type: String,
required: true,
},
updatedAt: {
type: String,
required: true,
},
taskStatus: {
type: String,
required: true,
},
},
data() {
return {
preAnimation: false,
pulseAnimation: false,
timeAgoEl: $('.js-issue-edited-ago'),
};
},
watch: {
descriptionHtml() {
this.animateChange();
this.$nextTick(() => {
const toolTipTime = gl.utils.formatDate(this.updatedAt);
this.timeAgoEl.attr('datetime', this.updatedAt)
.attr('title', toolTipTime)
.tooltip('fixTitle');
this.renderGFM();
});
},
taskStatus() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/);
const $issuableHeader = $('.issuable-meta');
const $tasks = $('#task_status', $issuableHeader);
const $tasksShort = $('#task_status_short', $issuableHeader);
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
$tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`);
} else {
$tasks.text('');
$tasksShort.text('');
}
},
},
methods: {
renderGFM() {
$(this.$refs['gfm-entry-content']).renderGFM();
if (this.canUpdate) {
// eslint-disable-next-line no-new
new gl.TaskList({
dataType: 'issue',
fieldName: 'description',
selector: '.detail-page-description',
});
}
},
},
mounted() {
this.renderGFM();
},
};
</script>
<template>
<div
class="description"
:class="{
'js-task-list-container': canUpdate
}">
<div
class="wiki"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation
}"
v-html="descriptionHtml"
ref="gfm-content">
</div>
<textarea
class="hidden js-task-list-field"
v-if="descriptionText"
v-model="descriptionText">
</textarea>
</div>
</template>

View File

@ -0,0 +1,53 @@
<script>
import animateMixin from '../mixins/animate';
export default {
mixins: [animateMixin],
data() {
return {
preAnimation: false,
pulseAnimation: false,
titleEl: document.querySelector('title'),
};
},
props: {
issuableRef: {
type: String,
required: true,
},
titleHtml: {
type: String,
required: true,
},
titleText: {
type: String,
required: true,
},
},
watch: {
titleHtml() {
this.setPageTitle();
this.animateChange();
},
},
methods: {
setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·');
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
},
};
</script>
<template>
<h2
class="title"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation
}"
v-html="titleHtml"
>
</h2>
</template>

View File

@ -1,20 +1,42 @@
import Vue from 'vue';
import IssueTitle from './issue_title_description.vue';
import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
(() => {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
const { canUpdateTasksClass, endpoint } = issueTitleData;
document.addEventListener('DOMContentLoaded', () => new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
data() {
const issuableElement = this.$options.el;
const issuableTitleElement = issuableElement.querySelector('.title');
const issuableDescriptionElement = issuableElement.querySelector('.wiki');
const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field');
const {
canUpdate,
endpoint,
issuableRef,
} = issuableElement.dataset;
const vm = new Vue({
el: '.issue-title-entrypoint',
render: createElement => createElement(IssueTitle, {
return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
endpoint,
issuableRef,
initialTitle: issuableTitleElement.innerHTML,
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
};
},
render(createElement) {
return createElement('issuable-app', {
props: {
canUpdateTasksClass,
endpoint,
canUpdate: this.canUpdate,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitle: this.initialTitle,
initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText,
},
}),
});
return vm;
})();
});
},
}));

View File

@ -1,180 +0,0 @@
<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>

View File

@ -0,0 +1,13 @@
export default {
methods: {
animateChange() {
this.preAnimation = true;
this.pulseAnimation = false;
this.$nextTick(() => {
this.preAnimation = false;
this.pulseAnimation = true;
});
},
},
};

View File

@ -1,10 +1,16 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class Service {
constructor(resource, endpoint) {
this.resource = resource;
constructor(endpoint) {
this.endpoint = endpoint;
this.resource = Vue.resource(this.endpoint);
}
getTitle() {
return this.resource.get(this.endpoint);
getData() {
return this.resource.get();
}
}

View File

@ -0,0 +1,25 @@
export default class Store {
constructor({
titleHtml,
descriptionHtml,
descriptionText,
}) {
this.state = {
titleHtml,
titleText: '',
descriptionHtml,
descriptionText,
taskStatus: '',
updatedAt: '',
};
}
updateState(data) {
this.state.titleHtml = data.title;
this.state.titleText = data.title_text;
this.state.descriptionHtml = data.description;
this.state.descriptionText = data.description_text;
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
}
}

View File

@ -112,7 +112,7 @@
}
}
.issue_edited_ago,
.issue-edited-ago,
.note_edited_ago {
display: none;
}

View File

@ -208,7 +208,6 @@ class Projects::IssuesController < Projects::ApplicationController
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

View File

@ -136,11 +136,9 @@ module IssuablesHelper
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg")
end
if issuable.tasks?
output << "&ensp;".html_safe
output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm")
output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg")
end
output << "&ensp;".html_safe
output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm")
output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg")
output
end

View File

@ -51,12 +51,17 @@
.issue-details.issuable-details
.detail-page-description.content-block
.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' : '',
#js-issuable-app{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue),
"can-update" => can?(current_user, :update_issue, @issue).to_s,
"issuable-ref" => @issue.to_reference,
} }
.issue-title-entrypoint
%h2.title= markdown_field(@issue, :title)
- 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 js-issue-edited-ago')
#merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript.

View File

@ -1,11 +1,8 @@
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.$ = $;
import issuableApp from '~/issue_show/components/app.vue';
import issueShowData from '../mock_data';
const issueShowInterceptor = data => (request, next) => {
next(request.respondWith(JSON.stringify(data), {
@ -16,13 +13,25 @@ const issueShowInterceptor = data => (request, next) => {
}));
};
describe('Issue Title', () => {
describe('Issuable output', () => {
document.body.innerHTML = '<span id="task_status"></span>';
let IssueTitleDescriptionComponent;
let vm;
beforeEach(() => {
IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription);
const IssuableDescriptionComponent = Vue.extend(issuableApp);
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
vm = new IssuableDescriptionComponent({
propsData: {
canUpdate: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title',
issuableRef: '#1',
initialTitle: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
},
}).$mount();
});
afterEach(() => {
@ -30,28 +39,19 @@ describe('Issue Title', () => {
});
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');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).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');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
done();
});

View File

@ -0,0 +1,99 @@
import Vue from 'vue';
import descriptionComponent from '~/issue_show/components/description.vue';
describe('Description component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(descriptionComponent);
if (!document.querySelector('.issuable-meta')) {
const metaData = document.createElement('div');
metaData.classList.add('issuable-meta');
metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>';
document.body.appendChild(metaData);
}
vm = new Component({
propsData: {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
},
}).$mount();
});
it('animates description changes', (done) => {
vm.descriptionHtml = 'changed';
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.wiki').classList.contains('issue-realtime-pre-pulse'),
).toBeTruthy();
setTimeout(() => {
expect(
vm.$el.querySelector('.wiki').classList.contains('issue-realtime-trigger-pulse'),
).toBeTruthy();
done();
});
});
});
it('re-inits the TaskList when description changed', (done) => {
spyOn(gl, 'TaskList');
vm.descriptionHtml = 'changed';
setTimeout(() => {
expect(
gl.TaskList,
).toHaveBeenCalled();
done();
});
});
it('does not re-init the TaskList when canUpdate is false', (done) => {
spyOn(gl, 'TaskList');
vm.canUpdate = false;
vm.descriptionHtml = 'changed';
setTimeout(() => {
expect(
gl.TaskList,
).not.toHaveBeenCalled();
done();
});
});
describe('taskStatus', () => {
it('adds full taskStatus', (done) => {
vm.taskStatus = '1 of 1';
setTimeout(() => {
expect(
document.querySelector('.issuable-meta #task_status').textContent.trim(),
).toBe('1 of 1');
done();
});
});
it('adds short taskStatus', (done) => {
vm.taskStatus = '1 of 1';
setTimeout(() => {
expect(
document.querySelector('.issuable-meta #task_status_short').textContent.trim(),
).toBe('1/1 task');
done();
});
});
});
});

View File

@ -0,0 +1,67 @@
import Vue from 'vue';
import titleComponent from '~/issue_show/components/title.vue';
describe('Title component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(titleComponent);
vm = new Component({
propsData: {
issuableRef: '#1',
titleHtml: 'Testing <img />',
titleText: 'Testing',
},
}).$mount();
});
it('renders title HTML', () => {
expect(
vm.$el.innerHTML.trim(),
).toBe('Testing <img>');
});
it('updates page title when changing titleHtml', (done) => {
spyOn(vm, 'setPageTitle');
vm.titleHtml = 'test';
Vue.nextTick(() => {
expect(
vm.setPageTitle,
).toHaveBeenCalled();
done();
});
});
it('animates title changes', (done) => {
vm.titleHtml = 'test';
Vue.nextTick(() => {
expect(
vm.$el.classList.contains('issue-realtime-pre-pulse'),
).toBeTruthy();
setTimeout(() => {
expect(
vm.$el.classList.contains('issue-realtime-trigger-pulse'),
).toBeTruthy();
done();
});
});
});
it('updates page title after changing title', (done) => {
vm.titleHtml = 'changed';
vm.titleText = 'changed';
Vue.nextTick(() => {
expect(
document.querySelector('title').textContent.trim(),
).toContain('changed');
done();
});
});
});

View File

@ -4,23 +4,23 @@ export default {
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',
updated_at: new Date().toString(),
},
secondRequest: {
title: '<p>2</p>',
title_text: '2',
description: '<p>42</p>',
description_text: '42',
issue_number: 1,
task_status: '0 of 0 completed',
updated_at: new Date().toString(),
},
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',
updated_at: new Date().toString(),
},
};