summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2017-05-18 09:09:17 +0000
committerPhil Hughes <me@iamphill.com>2017-05-18 09:09:17 +0000
commitdb0d52be11f0956d74ee64d461718871963451c6 (patch)
tree784b4f38e5458fbad0faba77b0faac2e04b9671f
parent1ef529a68e7a3b2ffc9d4a152fb386a1e8739605 (diff)
parent3accc58d0507b84d6c531e24a6274dd226ed5da2 (diff)
downloadgitlab-ce-db0d52be11f0956d74ee64d461718871963451c6.tar.gz
Merge branch 'fix-realtime-edited-text-for-issues' into '9-2-stable'
Fix realtime edited text for issues See merge request !11375
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue55
-rw-r--r--app/assets/javascripts/issue_show/index.js3
-rw-r--r--app/assets/javascripts/issue_show/issue_title_description.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue66
-rw-r--r--app/assets/stylesheets/framework/mobile.scss5
-rw-r--r--app/controllers/projects/issues_controller.rb11
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/models/concerns/editable.rb7
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/note.rb1
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/views/projects/issues/show.html.haml3
-rw-r--r--spec/javascripts/issue_show/components/edited_spec.js49
-rw-r--r--spec/javascripts/issue_show/issue_title_description_spec.js15
-rw-r--r--spec/javascripts/issue_show/mock_data.js9
-rw-r--r--spec/models/concerns/editable_spec.rb11
16 files changed, 246 insertions, 20 deletions
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
new file mode 100644
index 00000000000..f5038e55c09
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -0,0 +1,55 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small
+ class="edited-text"
+ >
+ Edited
+ <time-ago-tooltip
+ v-if="updatedAt"
+ placement="bottom"
+ :time="updatedAt"
+ />
+ <span
+ v-if="hasUpdatedBy"
+ >
+ by
+ <a
+ class="author_link"
+ :href="updatedByPath"
+ >
+ <span>{{updatedByName}}</span>
+ </a>
+ </span>
+ </small>
+</template>
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index eb20a597bb5..6dba7b90716 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -4,7 +4,7 @@ import '../vue_shared/vue_resource_interceptor';
(() => {
const issueTitleData = document.querySelector('.issue-title-data').dataset;
- const { canUpdateTasksClass, endpoint } = issueTitleData;
+ const { canUpdateTasksClass, endpoint, isEdited } = issueTitleData;
const vm = new Vue({
el: '.issue-title-entrypoint',
@@ -12,6 +12,7 @@ import '../vue_shared/vue_resource_interceptor';
props: {
canUpdateTasksClass,
endpoint,
+ isEdited,
},
}),
});
diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue
index dc3ba2550c5..3f77ba22d23 100644
--- a/app/assets/javascripts/issue_show/issue_title_description.vue
+++ b/app/assets/javascripts/issue_show/issue_title_description.vue
@@ -3,6 +3,7 @@ import Visibility from 'visibilityjs';
import Poll from './../lib/utils/poll';
import Service from './services/index';
import tasks from './actions/tasks';
+import edited from './components/edited.vue';
export default {
props: {
@@ -14,6 +15,11 @@ export default {
required: true,
type: String,
},
+ isEdited: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
data() {
const resource = new Service(this.$http, this.endpoint);
@@ -46,10 +52,13 @@ export default {
pre: true,
pulse: false,
},
- timeAgoEl: $('.issue_edited_ago'),
titleEl: document.querySelector('title'),
+ hasBeenEdited: this.isEdited,
};
},
+ components: {
+ edited,
+ },
methods: {
updateFlag(key, toggle) {
this[key].pre = toggle;
@@ -57,6 +66,9 @@ export default {
},
renderResponse(res) {
this.apiData = res.json();
+
+ if (this.apiData.updated_at) this.hasBeenEdited = true;
+
this.triggerAnimation();
},
updateTaskHTML() {
@@ -110,11 +122,6 @@ export default {
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()) {
@@ -132,8 +139,6 @@ export default {
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({
@@ -176,5 +181,11 @@ export default {
v-if="descriptionText"
>{{descriptionText}}</textarea>
</div>
+ <edited
+ v-if="hasBeenEdited"
+ :updated-at="apiData.updated_at"
+ :updated-by-name="apiData.updated_by_name"
+ :updated-by-path="apiData.updated_by_path"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
new file mode 100644
index 00000000000..934e7e8eacb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -0,0 +1,66 @@
+<script>
+import tooltipMixin from '../mixins/tooltip';
+import '../../lib/utils/datetime_utility';
+
+/**
+ * Port of ruby helper time_ago_with_tooltip
+ */
+
+export default {
+ props: {
+ time: {
+ type: String,
+ required: true,
+ },
+
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+
+ shortFormat: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ htmlClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ mixins: [tooltipMixin],
+
+ computed: {
+ cssClass() {
+ return this.shortFormat ? 'js-short-timeago' : 'js-timeago';
+ },
+
+ tooltipTitle() {
+ return gl.utils.formatDate(this.time);
+ },
+
+ timeFormated() {
+ const timeago = gl.utils.getTimeago();
+
+ return timeago.format(this.time);
+ },
+ },
+};
+</script>
+
+<template>
+ <time
+ :class="[cssClass, htmlClass]"
+ class="js-timeago js-timeago-render"
+ :title="tooltipTitle"
+ :data-placement="tooltipPlacement"
+ data-container="body"
+ ref="tooltip"
+ >
+ {{timeFormated}}
+ </time>
+</template>
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index eb73f7cc794..0140dcf19c3 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -112,11 +112,6 @@
}
}
- .issue_edited_ago,
- .note_edited_ago {
- display: none;
- }
-
aside:not(.right-sidebar) {
display: none;
}
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index bcd23d61519..7b1e4a70232 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -202,15 +202,22 @@ class Projects::IssuesController < Projects::ApplicationController
def rendered_title
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: {
+ response = {
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,
}
+
+ if @issue.is_edited?
+ response[:updated_at] = @issue.updated_at
+ response[:updated_by_name] = @issue.last_edited_by.name
+ response[:updated_by_path] = user_path(@issue.last_edited_by)
+ end
+
+ render json: response
end
def create_merge_request
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 6d6bcbaf88a..8c74d36ad81 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -181,7 +181,7 @@ module ApplicationHelper
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
- return if object.last_edited_at == object.created_at || object.last_edited_at.blank?
+ return unless object.is_edited?
content_tag :small, class: 'edited-text' do
output = content_tag(:span, 'Edited ')
diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb
new file mode 100644
index 00000000000..c62c7e1e936
--- /dev/null
+++ b/app/models/concerns/editable.rb
@@ -0,0 +1,7 @@
+module Editable
+ extend ActiveSupport::Concern
+
+ def is_edited?
+ last_edited_at.present? && last_edited_at != created_at
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 075ec575f9d..ea10d004c9c 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -15,6 +15,7 @@ module Issuable
include Taskable
include TimeTrackable
include Importable
+ include Editable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
diff --git a/app/models/note.rb b/app/models/note.rb
index 46d0a4f159f..4cb3c6f062a 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -13,6 +13,7 @@ class Note < ActiveRecord::Base
include AfterCommitQueue
include ResolvableNote
include IgnorableColumn
+ include Editable
ignore_column :original_discussion_id
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 882e2fa0594..6c3358685fe 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -8,6 +8,7 @@ class Snippet < ActiveRecord::Base
include Awardable
include Mentionable
include Spammable
+ include Editable
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 9084883eb3e..0ad615535d7 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -53,11 +53,10 @@
.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' : '',
+ "is-edited": @issue.is_edited?,
} }
.issue-title-entrypoint
- = 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) } }
// This element is filled in using JavaScript.
diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js
new file mode 100644
index 00000000000..a0d0750ae34
--- /dev/null
+++ b/spec/javascripts/issue_show/components/edited_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import edited from '~/issue_show/components/edited.vue';
+
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
+describe('edited', () => {
+ const EditedComponent = Vue.extend(edited);
+
+ it('should render an edited at+by string', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+
+ it('if no updatedAt is provided, no time element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ });
+
+ it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
+ const editedComponent = new EditedComponent({
+ propsData: {
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ },
+ }).$mount();
+
+ expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
+ expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy();
+ expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ });
+});
diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js
index 1ec4fe58b08..8180e67255c 100644
--- a/spec/javascripts/issue_show/issue_title_description_spec.js
+++ b/spec/javascripts/issue_show/issue_title_description_spec.js
@@ -7,6 +7,10 @@ import issueShowData from './mock_data';
window.$ = $;
+function formatText(text) {
+ return text.trim().replace(/\s\s+/g, ' ');
+}
+
const issueShowInterceptor = data => (request, next) => {
next(request.respondWith(JSON.stringify(data), {
status: 200,
@@ -29,7 +33,7 @@ describe('Issue Title', () => {
Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor);
});
- it('should render a title/description and update title/description on update', (done) => {
+ it('should render a title/description/edited and update title/description/edited on update', (done) => {
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
const issueShowComponent = new IssueTitleDescriptionComponent({
@@ -40,10 +44,15 @@ describe('Issue Title', () => {
}).$mount();
setTimeout(() => {
+ const editedText = issueShowComponent.$el.querySelector('.edited-text');
+
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(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
@@ -52,6 +61,10 @@ describe('Issue Title', () => {
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(issueShowComponent.$el.querySelector('.edited-text')).toBeTruthy();
+ expect(formatText(issueShowComponent.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/);
+ expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/);
+ expect(editedText.querySelector('time')).toBeTruthy();
done();
});
diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js
index ad5a7b63470..a4562449ff1 100644
--- a/spec/javascripts/issue_show/mock_data.js
+++ b/spec/javascripts/issue_show/mock_data.js
@@ -6,6 +6,9 @@ export default {
description_text: 'this is a description',
issue_number: 1,
task_status: '2 of 4 completed',
+ updated_at: '2015-05-15T12:31:04.428Z',
+ updated_by_name: 'Some User',
+ updated_by_path: '/some_user',
},
secondRequest: {
title: '<p>2</p>',
@@ -14,6 +17,9 @@ export default {
description_text: '42',
issue_number: 1,
task_status: '0 of 0 completed',
+ updated_at: '2016-05-15T12:31:04.428Z',
+ updated_by_name: 'Other User',
+ updated_by_path: '/other_user',
},
issueSpecRequest: {
title: '<p>this is a title</p>',
@@ -22,5 +28,8 @@ export default {
description_text: '- [ ] Task List Item',
issue_number: 1,
task_status: '0 of 1 completed',
+ updated_at: '2017-05-15T12:31:04.428Z',
+ updated_by_name: 'Last User',
+ updated_by_path: '/last_user',
},
};
diff --git a/spec/models/concerns/editable_spec.rb b/spec/models/concerns/editable_spec.rb
new file mode 100644
index 00000000000..cd73af3b480
--- /dev/null
+++ b/spec/models/concerns/editable_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Editable do
+ describe '#is_edited?' do
+ let(:issue) { create(:issue, last_edited_at: nil) }
+ let(:edited_issue) { create(:issue, created_at: 3.days.ago, last_edited_at: 2.days.ago) }
+
+ it { expect(issue.is_edited?).to eq(false) }
+ it { expect(edited_issue.is_edited?).to eq(true) }
+ end
+end