FEATURE: revert post to a specific revision

This commit is contained in:
Arpit Jalan 2016-03-09 21:10:49 +05:30
parent a212540779
commit 89248580dc
9 changed files with 166 additions and 6 deletions

View File

@ -29,6 +29,25 @@ export default Ember.Controller.extend(ModalFunctionality, {
Discourse.Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion)); Discourse.Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion));
}, },
revert(post, postVersion) {
post.revertToRevision(postVersion).then((result) => {
this.refresh(post.get('id'), postVersion);
if (result.topic) {
post.set('topic.slug', result.topic.slug);
post.set('topic.title', result.topic.title);
post.set('topic.fancy_title', result.topic.fancy_title);
}
if (result.category_id) {
post.set('topic.category', Discourse.Category.findById(result.category_id));
}
this.send("closeModal");
}).catch(function(e) {
if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors && e.jqXHR.responseJSON.errors[0]) {
bootbox.alert(e.jqXHR.responseJSON.errors[0]);
}
});
},
@computed('model.created_at') @computed('model.created_at')
createdAtDate(createdAt) { createdAtDate(createdAt) {
return moment(createdAt).format("LLLL"); return moment(createdAt).format("LLLL");
@ -69,6 +88,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
return !prevHidden && this.currentUser && this.currentUser.get('staff'); return !prevHidden && this.currentUser && this.currentUser.get('staff');
}, },
@computed()
displayRevert() {
return this.currentUser && this.currentUser.get('staff');
},
isEitherRevisionHidden: Ember.computed.or("model.previous_hidden", "model.current_hidden"), isEitherRevisionHidden: Ember.computed.or("model.previous_hidden", "model.current_hidden"),
@computed('model.previous_hidden', 'model.current_hidden', 'displayingInline') @computed('model.previous_hidden', 'model.current_hidden', 'displayingInline')
@ -142,6 +166,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
hideVersion() { this.hide(this.get("model.post_id"), this.get("model.current_revision")); }, hideVersion() { this.hide(this.get("model.post_id"), this.get("model.current_revision")); },
showVersion() { this.show(this.get("model.post_id"), this.get("model.current_revision")); }, showVersion() { this.show(this.get("model.post_id"), this.get("model.current_revision")); },
revertToVersion() { this.revert(this.get("post"), this.get("model.current_revision")); },
displayInline() { this.set("viewMode", "inline"); }, displayInline() { this.set("viewMode", "inline"); },
displaySideBySide() { this.set("viewMode", "side_by_side"); }, displaySideBySide() { this.set("viewMode", "side_by_side"); },
displaySideBySideMarkdown() { this.set("viewMode", "side_by_side_markdown"); } displaySideBySideMarkdown() { this.set("viewMode", "side_by_side_markdown"); }

View File

@ -271,6 +271,10 @@ const Post = RestModel.extend({
json = Post.munge(json); json = Post.munge(json);
this.set('actions_summary', json.actions_summary); this.set('actions_summary', json.actions_summary);
} }
},
revertToRevision(version) {
return Discourse.ajax(`/posts/${this.get('id')}/revisions/${version}/revert`, { type: 'PUT' });
} }
}); });

View File

@ -73,6 +73,7 @@ const TopicRoute = Discourse.Route.extend({
showHistory(model) { showHistory(model) {
showModal('history', { model }); showModal('history', { model });
this.controllerFor('history').refresh(model.get("id"), "latest"); this.controllerFor('history').refresh(model.get("id"), "latest");
this.controllerFor('history').set('post', model);
this.controllerFor('modal').set('modalClass', 'history-modal'); this.controllerFor('modal').set('modalClass', 'history-modal');
}, },

View File

@ -10,12 +10,6 @@
</div> </div>
{{d-button action="loadNextVersion" icon="forward" title="post.revisions.controls.next" disabled=loadNextDisabled}} {{d-button action="loadNextVersion" icon="forward" title="post.revisions.controls.next" disabled=loadNextDisabled}}
{{d-button action="loadLastVersion" icon="fast-forward" title="post.revisions.controls.last" disabled=loadLastDisabled}} {{d-button action="loadLastVersion" icon="fast-forward" title="post.revisions.controls.last" disabled=loadLastDisabled}}
{{#if displayHide}}
{{d-button action="hideVersion" icon="trash-o" title="post.revisions.controls.hide" class="btn-danger" disabled=loading}}
{{/if}}
{{#if displayShow}}
{{d-button action="showVersion" icon="undo" title="post.revisions.controls.show" disabled=loading}}
{{/if}}
</div> </div>
<div id="display-modes"> <div id="display-modes">
{{d-button action="displayInline" label="post.revisions.displays.inline.button" title="post.revisions.displays.inline.title" class=inlineClass}} {{d-button action="displayInline" label="post.revisions.displays.inline.button" title="post.revisions.displays.inline.title" class=inlineClass}}
@ -85,5 +79,15 @@
<div class="row"> <div class="row">
{{{bodyDiff}}} {{{bodyDiff}}}
</div> </div>
{{#if displayRevert}}
{{d-button action="revertToVersion" icon="undo" label="post.revisions.controls.revert" class="btn-danger" disabled=loading}}
{{/if}}
{{#if displayHide}}
{{d-button action="hideVersion" icon="eye-slash" label="post.revisions.controls.hide" class="btn-danger" disabled=loading}}
{{/if}}
{{#if displayShow}}
{{d-button action="showVersion" icon="eye" label="post.revisions.controls.show" disabled=loading}}
{{/if}}
</div> </div>
</div> </div>

View File

@ -282,6 +282,55 @@ class PostsController < ApplicationController
render nothing: true render nothing: true
end end
def revert
raise Discourse::NotFound unless guardian.is_staff?
post_id = params[:id] || params[:post_id]
revision = params[:revision].to_i
raise Discourse::InvalidParameters.new(:revision) if revision < 2
post_revision = PostRevision.find_by(post_id: post_id, number: revision)
raise Discourse::NotFound unless post_revision
post = find_post_from_params
raise Discourse::NotFound if post.blank?
post_revision.post = post
guardian.ensure_can_see!(post_revision)
guardian.ensure_can_edit!(post)
return render_json_error(I18n.t('revert_version_same')) if post_revision.modifications["raw"].blank? && post_revision.modifications["title"].blank? && post_revision.modifications["category_id"].blank?
topic = Topic.with_deleted.find(post.topic_id)
changes = {}
changes[:raw] = post_revision.modifications["raw"][0] if post_revision.modifications["raw"].present? && post_revision.modifications["raw"][0] != post.raw
if post.is_first_post?
changes[:title] = post_revision.modifications["title"][0] if post_revision.modifications["title"].present? && post_revision.modifications["title"][0] != topic.title
changes[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present? && post_revision.modifications["category_id"][0] != topic.category.id
end
return render_json_error(I18n.t('revert_version_same')) unless changes.length > 0
changes[:edit_reason] = "reverted to version ##{post_revision.number.to_i - 1}"
revisor = PostRevisor.new(post, topic)
revisor.revise!(current_user, changes)
return render_json_error(post) if post.errors.present?
return render_json_error(topic) if topic.errors.present?
post_serializer = PostSerializer.new(post, scope: guardian, root: false)
post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key)
link_counts = TopicLink.counts_for(guardian, topic, [post])
post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present?
result = { post: post_serializer.as_json }
if post.is_first_post?
result[:topic] = BasicTopicSerializer.new(topic, scope: guardian, root: false).as_json if post_revision.modifications["title"].present?
result[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present?
end
render_json_dump(result)
end
def bookmark def bookmark
post = find_post_from_params post = find_post_from_params

View File

@ -1639,6 +1639,7 @@ en:
last: "Last revision" last: "Last revision"
hide: "Hide revision" hide: "Hide revision"
show: "Show revision" show: "Show revision"
revert: "Revert to this revision"
comparing_previous_to_current_out_of_total: "<strong>{{previous}}</strong> <i class='fa fa-arrows-h'></i> <strong>{{current}}</strong> / {{total}}" comparing_previous_to_current_out_of_total: "<strong>{{previous}}</strong> <i class='fa fa-arrows-h'></i> <strong>{{current}}</strong> / {{total}}"
displays: displays:
inline: inline:

View File

@ -204,6 +204,7 @@ en:
top: "Top topics" top: "Top topics"
posts: "Latest posts" posts: "Latest posts"
too_late_to_edit: "That post was created too long ago. It can no longer be edited or deleted." too_late_to_edit: "That post was created too long ago. It can no longer be edited or deleted."
revert_version_same: "The current version is same as the version you are trying to revert to."
excerpt_image: "image" excerpt_image: "image"

View File

@ -386,6 +386,7 @@ Discourse::Application.routes.draw do
get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ } get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ }
put "revisions/:revision/hide" => "posts#hide_revision", constraints: { revision: /\d+/ } put "revisions/:revision/hide" => "posts#hide_revision", constraints: { revision: /\d+/ }
put "revisions/:revision/show" => "posts#show_revision", constraints: { revision: /\d+/ } put "revisions/:revision/show" => "posts#show_revision", constraints: { revision: /\d+/ }
put "revisions/:revision/revert" => "posts#revert", constraints: { revision: /\d+/ }
put "recover" put "recover"
collection do collection do
delete "destroy_many" delete "destroy_many"

View File

@ -830,6 +830,79 @@ describe PostsController do
end end
describe 'revert post to a specific revision' do
include_examples 'action requires login', :put, :revert, post_id: 123, revision: 2
let(:post) { Fabricate(:post, user: logged_in_as, raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex") }
let(:post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["this is original post body.", "this is edited post body."]}) }
let(:blank_post_revision) { Fabricate(:post_revision, post: post, modifications: {"edit_reason" => ["edit reason #1", "edit reason #2"]}) }
let(:same_post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", "this is edited post body."]}) }
let(:revert_params) do
{
post_id: post.id,
revision: post_revision.number
}
end
let(:moderator) { Fabricate(:moderator) }
describe 'when logged in as a regular user' do
let(:logged_in_as) { log_in }
it "does not work" do
xhr :put, :revert, revert_params
expect(response).to_not be_success
end
end
describe "when logged in as staff" do
let(:logged_in_as) { log_in(:moderator) }
it "throws an exception when revision is < 2" do
expect {
xhr :put, :revert, post_id: post.id, revision: 1
}.to raise_error(Discourse::InvalidParameters)
end
it "fails when post_revision record is not found" do
xhr :put, :revert, post_id: post.id, revision: post_revision.number + 1
expect(response).to_not be_success
end
it "fails when post record is not found" do
xhr :put, :revert, post_id: post.id + 1, revision: post_revision.number
expect(response).to_not be_success
end
it "fails when revision is blank" do
xhr :put, :revert, post_id: post.id, revision: blank_post_revision.number
expect(response.status).to eq(422)
expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same'))
end
it "fails when revised version is same as current version" do
xhr :put, :revert, post_id: post.id, revision: same_post_revision.number
expect(response.status).to eq(422)
expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same'))
end
it "works!" do
xhr :put, :revert, revert_params
expect(response).to be_success
end
it "supports reverting posts in deleted topics" do
first_post = post.topic.ordered_posts.first
PostDestroyer.new(moderator, first_post).destroy
xhr :put, :revert, revert_params
expect(response).to be_success
end
end
end
describe 'expandable embedded posts' do describe 'expandable embedded posts' do
let(:post) { Fabricate(:post) } let(:post) { Fabricate(:post) }