Refactored issue tealtime elements
This is to match our docs better and will also help a future issue. Also made it possible for the description & title to be readable when JS is disabled
This commit is contained in:
parent
566ee14516
commit
5a95d6f8da
10 changed files with 327 additions and 225 deletions
|
@ -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();
|
||||
}
|
||||
};
|
95
app/assets/javascripts/issue_show/components/app.vue
Normal file
95
app/assets/javascripts/issue_show/components/app.vue
Normal file
|
@ -0,0 +1,95 @@
|
|||
<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({
|
||||
title: 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
|
||||
:can-update="canUpdate"
|
||||
:description-html="state.descriptionHtml"
|
||||
:description-text="state.descriptionText"
|
||||
:updated-at="state.updatedAt"
|
||||
:task-status="state.taskStatus" />
|
||||
</div>
|
||||
</template>
|
100
app/assets/javascripts/issue_show/components/description.vue
Normal file
100
app/assets/javascripts/issue_show/components/description.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<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: $('.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.$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',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
taskStatus() {
|
||||
const $issueableHeader = $('.issuable-header');
|
||||
let $tasks = $('#task_status');
|
||||
let $tasksShort = $('#task_status_short');
|
||||
|
||||
if (this.taskStatus.indexOf('0 of 0') >= 0) {
|
||||
$tasks.remove();
|
||||
$tasksShort.remove();
|
||||
} else if (!$tasks.length && !$tasksShort.length) {
|
||||
$tasks = $issueableHeader.append('<span id="task_status"></span>');
|
||||
$tasksShort = $issueableHeader.append('<span id="task_status_short"></span>');
|
||||
}
|
||||
|
||||
$tasks.text(this.taskStatus);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="descriptionHtml"
|
||||
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"
|
||||
>{{ descriptionText }}</textarea>
|
||||
</div>
|
||||
</template>
|
53
app/assets/javascripts/issue_show/components/title.vue
Normal file
53
app/assets/javascripts/issue_show/components/title.vue
Normal 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>
|
|
@ -1,20 +1,32 @@
|
|||
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', () => {
|
||||
const issuableElement = document.getElementById('js-issuable-app');
|
||||
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 new Vue({
|
||||
el: issuableElement,
|
||||
components: {
|
||||
issuableApp,
|
||||
},
|
||||
render: createElement => createElement('issuable-app', {
|
||||
props: {
|
||||
canUpdateTasksClass,
|
||||
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
|
||||
endpoint,
|
||||
issuableRef,
|
||||
initialTitle: issuableTitleElement.innerHTML,
|
||||
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
|
||||
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return vm;
|
||||
})();
|
||||
});
|
||||
|
|
|
@ -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>
|
13
app/assets/javascripts/issue_show/mixins/animate.js
Normal file
13
app/assets/javascripts/issue_show/mixins/animate.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
export default {
|
||||
methods: {
|
||||
animateChange() {
|
||||
this.preAnimation = true;
|
||||
this.pulseAnimation = false;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.preAnimation = false;
|
||||
this.pulseAnimation = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
25
app/assets/javascripts/issue_show/stores/index.js
Normal file
25
app/assets/javascripts/issue_show/stores/index.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
export default class Store {
|
||||
constructor({
|
||||
title,
|
||||
descriptionHtml,
|
||||
descriptionText,
|
||||
}) {
|
||||
this.state = {
|
||||
titleHtml: title,
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -51,10 +51,15 @@
|
|||
|
||||
.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')
|
||||
|
||||
|
|
Loading…
Reference in a new issue