diff options
-rw-r--r-- | app/assets/javascripts/issue_show/components/edited.vue | 55 | ||||
-rw-r--r-- | app/assets/javascripts/issue_show/index.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/issue_show/issue_title_description.vue | 27 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue | 66 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/mobile.scss | 5 | ||||
-rw-r--r-- | app/controllers/projects/issues_controller.rb | 11 | ||||
-rw-r--r-- | app/helpers/application_helper.rb | 2 | ||||
-rw-r--r-- | app/models/concerns/editable.rb | 7 | ||||
-rw-r--r-- | app/models/concerns/issuable.rb | 1 | ||||
-rw-r--r-- | app/models/note.rb | 1 | ||||
-rw-r--r-- | app/models/snippet.rb | 1 | ||||
-rw-r--r-- | app/views/projects/issues/show.html.haml | 3 | ||||
-rw-r--r-- | spec/javascripts/issue_show/components/edited_spec.js | 49 | ||||
-rw-r--r-- | spec/javascripts/issue_show/issue_title_description_spec.js | 15 | ||||
-rw-r--r-- | spec/javascripts/issue_show/mock_data.js | 9 | ||||
-rw-r--r-- | spec/models/concerns/editable_spec.rb | 11 |
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 |