Commit 07c984d8 authored by Luke "Jared" Bennett's avatar Luke "Jared" Bennett

Port fix-realtime-edited-text-for-issues 9-2-stable fix to master.

parent 228926da
...@@ -5,6 +5,7 @@ import Service from '../services/index'; ...@@ -5,6 +5,7 @@ import Service from '../services/index';
import Store from '../stores'; import Store from '../stores';
import titleComponent from './title.vue'; import titleComponent from './title.vue';
import descriptionComponent from './description.vue'; import descriptionComponent from './description.vue';
import editedComponent from './edited.vue';
export default { export default {
props: { props: {
...@@ -34,12 +35,30 @@ export default { ...@@ -34,12 +35,30 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
updatedAt: {
type: String,
required: false,
default: '',
},
updatedByName: {
type: String,
required: false,
default: '',
},
updatedByPath: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
titleHtml: this.initialTitle, titleHtml: this.initialTitle,
descriptionHtml: this.initialDescriptionHtml, descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText, descriptionText: this.initialDescriptionText,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
}); });
return { return {
...@@ -50,6 +69,7 @@ export default { ...@@ -50,6 +69,7 @@ export default {
components: { components: {
descriptionComponent, descriptionComponent,
titleComponent, titleComponent,
editedComponent,
}, },
created() { created() {
const resource = new Service(this.endpoint); const resource = new Service(this.endpoint);
...@@ -90,7 +110,12 @@ export default { ...@@ -90,7 +110,12 @@ export default {
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"
:description-text="state.descriptionText" :description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" /> :task-status="state.taskStatus" />
<edited-component
v-if="!!state.updatedAt"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath"
/>
</div> </div>
</template> </template>
...@@ -16,10 +16,6 @@ ...@@ -16,10 +16,6 @@
type: String, type: String,
required: true, required: true,
}, },
updatedAt: {
type: String,
required: true,
},
taskStatus: { taskStatus: {
type: String, type: String,
required: true, required: true,
...@@ -29,7 +25,6 @@ ...@@ -29,7 +25,6 @@
return { return {
preAnimation: false, preAnimation: false,
pulseAnimation: false, pulseAnimation: false,
timeAgoEl: $('.js-issue-edited-ago'),
}; };
}, },
watch: { watch: {
...@@ -37,12 +32,6 @@ ...@@ -37,12 +32,6 @@
this.animateChange(); this.animateChange();
this.$nextTick(() => { this.$nextTick(() => {
const toolTipTime = gl.utils.formatDate(this.updatedAt);
this.timeAgoEl.attr('datetime', this.updatedAt)
.attr('title', toolTipTime)
.tooltip('fixTitle');
this.renderGFM(); this.renderGFM();
}); });
}, },
......
<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>
...@@ -12,10 +12,14 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -12,10 +12,14 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
const issuableTitleElement = issuableElement.querySelector('.title'); const issuableTitleElement = issuableElement.querySelector('.title');
const issuableDescriptionElement = issuableElement.querySelector('.wiki'); const issuableDescriptionElement = issuableElement.querySelector('.wiki');
const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field');
const { const {
canUpdate, canUpdate,
endpoint, endpoint,
issuableRef, issuableRef,
updatedAt,
updatedByName,
updatedByPath,
} = issuableElement.dataset; } = issuableElement.dataset;
return { return {
...@@ -25,6 +29,9 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -25,6 +29,9 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
initialTitle: issuableTitleElement.innerHTML, initialTitle: issuableTitleElement.innerHTML,
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
updatedAt,
updatedByName,
updatedByPath,
}; };
}, },
render(createElement) { render(createElement) {
...@@ -36,6 +43,9 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -36,6 +43,9 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
initialTitle: this.initialTitle, initialTitle: this.initialTitle,
initialDescriptionHtml: this.initialDescriptionHtml, initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText, initialDescriptionText: this.initialDescriptionText,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
}, },
}); });
}, },
......
...@@ -3,6 +3,9 @@ export default class Store { ...@@ -3,6 +3,9 @@ export default class Store {
titleHtml, titleHtml,
descriptionHtml, descriptionHtml,
descriptionText, descriptionText,
updatedAt,
updatedByName,
updatedByPath,
}) { }) {
this.state = { this.state = {
titleHtml, titleHtml,
...@@ -10,7 +13,9 @@ export default class Store { ...@@ -10,7 +13,9 @@ export default class Store {
descriptionHtml, descriptionHtml,
descriptionText, descriptionText,
taskStatus: '', taskStatus: '',
updatedAt: '', updatedAt,
updatedByName,
updatedByPath,
}; };
} }
...@@ -21,5 +26,7 @@ export default class Store { ...@@ -21,5 +26,7 @@ export default class Store {
this.state.descriptionText = data.description_text; this.state.descriptionText = data.description_text;
this.state.taskStatus = data.task_status; this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at; this.state.updatedAt = data.updated_at;
this.state.updatedByName = data.updated_by_name;
this.state.updatedByPath = data.updated_by_path;
} }
} }
...@@ -112,11 +112,6 @@ ...@@ -112,11 +112,6 @@
} }
} }
.issue-edited-ago,
.note_edited_ago {
display: none;
}
aside:not(.right-sidebar) { aside:not(.right-sidebar) {
display: none; display: none;
} }
......
...@@ -202,14 +202,21 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -202,14 +202,21 @@ class Projects::IssuesController < Projects::ApplicationController
def realtime_changes def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000) Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { response = {
title: view_context.markdown_field(@issue, :title), title: view_context.markdown_field(@issue, :title),
title_text: @issue.title, title_text: @issue.title,
description: view_context.markdown_field(@issue, :description), description: view_context.markdown_field(@issue, :description),
description_text: @issue.description, description_text: @issue.description,
task_status: @issue.task_status, task_status: @issue.task_status
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 end
def create_merge_request def create_merge_request
......
...@@ -181,7 +181,7 @@ module ApplicationHelper ...@@ -181,7 +181,7 @@ module ApplicationHelper
end end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) 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 content_tag :small, class: 'edited-text' do
output = content_tag(:span, 'Edited ') output = content_tag(:span, 'Edited ')
......
module EditableHelper
def updated_at_by(editable)
return nil unless editable.is_edited?
{
updated_at: editable.updated_at,
updated_by: {
name: editable.last_edited_by.name,
path: user_path(editable.last_edited_by)
}
}
end
end
module Editable
extend ActiveSupport::Concern
def is_edited?
last_edited_at.present? && last_edited_at != created_at
end
end
...@@ -15,6 +15,7 @@ module Issuable ...@@ -15,6 +15,7 @@ module Issuable
include Taskable include Taskable
include TimeTrackable include TimeTrackable
include Importable include Importable
include Editable
# This object is used to gather issuable meta data for displaying # This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests # upvotes, downvotes, notes and closing merge requests count for issues and merge requests
......
...@@ -13,6 +13,7 @@ class Note < ActiveRecord::Base ...@@ -13,6 +13,7 @@ class Note < ActiveRecord::Base
include AfterCommitQueue include AfterCommitQueue
include ResolvableNote include ResolvableNote
include IgnorableColumn include IgnorableColumn
include Editable
ignore_column :original_discussion_id ignore_column :original_discussion_id
......
...@@ -8,6 +8,7 @@ class Snippet < ActiveRecord::Base ...@@ -8,6 +8,7 @@ class Snippet < ActiveRecord::Base
include Awardable include Awardable
include Mentionable include Mentionable
include Spammable include Spammable
include Editable
cache_markdown_field :title, pipeline: :single_line cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :content cache_markdown_field :content
......
...@@ -58,14 +58,14 @@ ...@@ -58,14 +58,14 @@
#js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue), #js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue),
"can-update" => can?(current_user, :update_issue, @issue).to_s, "can-update" => can?(current_user, :update_issue, @issue).to_s,
"issuable-ref" => @issue.to_reference, "issuable-ref" => @issue.to_reference,
} } }.merge(updated_at_by(@issue)) }
%h2.title= markdown_field(@issue, :title) %h2.title= markdown_field(@issue, :title)
- if @issue.description.present? - if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
.wiki= markdown_field(@issue, :description) .wiki= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field= @issue.description %textarea.hidden.js-task-list-field= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-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) } } #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } }
// This element is filled in using JavaScript. // This element is filled in using JavaScript.
......
require 'spec_helper'
describe EditableHelper do
describe '#updated_at_by' do
let(:user) { create(:user) }
let(:unedited_editable) { create(:issue) }
let(:edited_editable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) }
let(:edited_updated_at_by) do
{
updated_at: edited_editable.updated_at,
updated_by: {
name: user.name,
path: user_path(user)
}
}
end
it { expect(helper.updated_at_by(unedited_editable)).to eq(nil) }
it { expect(helper.updated_at_by(edited_editable)).to eq(edited_updated_at_by) }
end
end
...@@ -13,6 +13,10 @@ const issueShowInterceptor = data => (request, next) => { ...@@ -13,6 +13,10 @@ const issueShowInterceptor = data => (request, next) => {
})); }));
}; };
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
describe('Issuable output', () => { describe('Issuable output', () => {
document.body.innerHTML = '<span id="task_status"></span>'; document.body.innerHTML = '<span id="task_status"></span>';
...@@ -38,12 +42,17 @@ describe('Issuable output', () => { ...@@ -38,12 +42,17 @@ describe('Issuable output', () => {
Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); 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) => {
setTimeout(() => { setTimeout(() => {
const editedText = vm.$el.querySelector('.edited-text');
expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); 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('.wiki').innerHTML).toContain('<p>this is a description!</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description'); expect(vm.$el.querySelector('.js-task-list-field').value).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)); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest));
...@@ -52,6 +61,10 @@ describe('Issuable output', () => { ...@@ -52,6 +61,10 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>');
expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>');
expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42');
expect(vm.$el.querySelector('.edited-text')).toBeTruthy();
expect(formatText(vm.$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(); done();
}); });
......
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();
});
});
...@@ -5,7 +5,9 @@ export default { ...@@ -5,7 +5,9 @@ export default {
description: '<p>this is a description!</p>', description: '<p>this is a description!</p>',
description_text: 'this is a description', description_text: 'this is a description',
task_status: '2 of 4 completed', task_status: '2 of 4 completed',
updated_at: new Date().toString(), updated_at: '2015-05-15T12:31:04.428Z',
updated_by_name: 'Some User',
updated_by_path: '/some_user',
}, },
secondRequest: { secondRequest: {
title: '<p>2</p>', title: '<p>2</p>',
...@@ -13,7 +15,9 @@ export default { ...@@ -13,7 +15,9 @@ export default {
description: '<p>42</p>', description: '<p>42</p>',
description_text: '42', description_text: '42',
task_status: '0 of 0 completed', task_status: '0 of 0 completed',
updated_at: new Date().toString(), updated_at: '2016-05-15T12:31:04.428Z',
updated_by_name: 'Other User',
updated_by_path: '/other_user',
}, },
issueSpecRequest: { issueSpecRequest: {
title: '<p>this is a title</p>', title: '<p>this is a title</p>',
...@@ -21,6 +25,8 @@ export default { ...@@ -21,6 +25,8 @@ export default {
description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>',
description_text: '- [ ] Task List Item', description_text: '- [ ] Task List Item',
task_status: '0 of 1 completed', task_status: '0 of 1 completed',
updated_at: new Date().toString(), updated_at: '2017-05-15T12:31:04.428Z',
updated_by_name: 'Last User',
updated_by_path: '/last_user',
}, },
}; };
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment