Select +Replies for bulk operations

This commit is contained in:
Robin Ward 2013-09-04 11:53:00 -04:00
parent dba1d79de2
commit f157ec1f91
20 changed files with 282 additions and 77 deletions

View file

@ -12,6 +12,7 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel
topicController: Em.computed.alias('controllers.topic'),
selectedPosts: Em.computed.alias('topicController.selectedPosts'),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
allPostsSelected: Em.computed.alias('topicController.allPostsSelected'),
buttonDisabled: function() {
@ -31,10 +32,13 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel
if (this.get('allPostsSelected')) {
promise = Discourse.Topic.mergeTopic(this.get('id'), this.get('selectedTopicId'));
} else {
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }),
replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); });
promise = Discourse.Topic.movePosts(this.get('id'), {
destination_topic_id: this.get('selectedTopicId'),
post_ids: postIds
post_ids: postIds,
reply_post_ids: replyPostIds
});
}

View file

@ -12,6 +12,7 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel
topicController: Em.computed.alias('controllers.topic'),
selectedPosts: Em.computed.alias('topicController.selectedPosts'),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
buttonDisabled: function() {
if (this.get('saving')) return true;
@ -30,21 +31,23 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel
movePostsToNewTopic: function() {
this.set('saving', true);
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
var splitTopicController = this;
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }),
replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }),
self = this;
Discourse.Topic.movePosts(this.get('id'), {
title: this.get('topicName'),
post_ids: postIds
post_ids: postIds,
reply_post_ids: replyPostIds
}).then(function(result) {
// Posts moved
splitTopicController.send('closeModal');
splitTopicController.get('topicController').toggleMultiSelect();
self.send('closeModal');
self.get('topicController').toggleMultiSelect();
Em.run.next(function() { Discourse.URL.routeTo(result.url); });
}, function() {
// Error moving posts
splitTopicController.flash(I18n.t('topic.split_topic.error'));
splitTopicController.set('saving', false);
self.flash(I18n.t('topic.split_topic.error'));
self.set('saving', false);
});
return false;
}

View file

@ -11,8 +11,15 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
summaryCollapsed: true,
needs: ['header', 'modal', 'composer', 'quoteButton'],
allPostsSelected: false,
selectedPosts: new Em.Set(),
editingTopic: false,
selectedPosts: null,
selectedReplies: null,
init: function() {
this._super();
this.set('selectedPosts', new Em.Set());
this.set('selectedReplies', new Em.Set());
},
jumpTopDisabled: function() {
return (this.get('progressPosition') === 1);
@ -82,18 +89,49 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
return false;
}.property('postStream.loaded', 'currentPost', 'postStream.filteredPostsCount'),
selectPost: function(post) {
deselectPost: function(post) {
this.get('selectedPosts').removeObject(post);
var selectedReplies = this.get('selectedReplies');
selectedReplies.removeObject(post);
var selectedReply = selectedReplies.findProperty('post_number', post.get('reply_to_post_number'));
if (selectedReply) { selectedReplies.removeObject(selectedReply); }
this.set('allPostsSelected', false);
},
postSelected: function(post) {
if (this.get('allPostsSelected')) { return true; }
if (this.get('selectedPosts').contains(post)) { return true; }
if (this.get('selectedReplies').findProperty('post_number', post.get('reply_to_post_number'))) { return true; }
return false;
},
toggledSelectedPost: function(post) {
var selectedPosts = this.get('selectedPosts');
if (selectedPosts.contains(post)) {
selectedPosts.removeObject(post);
this.set('allPostsSelected', false);
if (this.postSelected(post)) {
this.deselectPost(post);
return false;
} else {
selectedPosts.addObject(post);
// If the user manually selects all posts, all posts are selected
if (selectedPosts.length === this.get('posts_count')) {
this.set('allPostsSelected');
this.set('allPostsSelected', true);
}
return true;
}
},
toggledSelectedPostReplies: function(post) {
var selectedReplies = this.get('selectedReplies');
if (this.toggledSelectedPost(post)) {
selectedReplies.addObject(post);
} else {
selectedReplies.removeObject(post);
}
},
@ -108,6 +146,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
deselectAll: function() {
this.get('selectedPosts').clear();
this.get('selectedReplies').clear();
this.set('allPostsSelected', false);
},
@ -177,19 +216,28 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
},
deleteSelected: function() {
var topicController = this;
var self = this;
bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) {
if (result) {
// If all posts are selected, it's the same thing as deleting the topic
if (topicController.get('allPostsSelected')) {
return topicController.deleteTopic();
if (self.get('allPostsSelected')) {
return self.deleteTopic();
}
var selectedPosts = topicController.get('selectedPosts');
Discourse.Post.deleteMany(selectedPosts);
topicController.get('model.postStream').removePosts(selectedPosts);
topicController.toggleMultiSelect();
var selectedPosts = self.get('selectedPosts'),
selectedReplies = self.get('selectedReplies'),
postStream = self.get('postStream'),
toRemove = new Ember.Set();
Discourse.Post.deleteMany(selectedPosts, selectedReplies);
postStream.get('posts').forEach(function (p) {
if (self.postSelected(p)) { toRemove.addObject(p); }
});
postStream.removePosts(toRemove);
self.toggleMultiSelect();
}
});
},

View file

@ -11,10 +11,15 @@ Discourse.SelectedPostsCount = Em.Mixin.create({
selectedPostsCount: function() {
if (this.get('allPostsSelected')) return this.get('posts_count') || this.get('topic.posts_count');
if (!this.get('selectedPosts')) return 0;
var sum = this.get('selectedPosts.length') || 0;
if (this.get('selectedReplies')) {
this.get('selectedReplies').forEach(function (p) {
sum += p.get('reply_count') || 0;
});
}
return this.get('selectedPosts.length');
}.property('selectedPosts.length', 'allPostsSelected')
return sum;
}.property('selectedPosts.length', 'allPostsSelected', 'selectedReplies.length')
});

View file

@ -328,8 +328,7 @@ Discourse.Post = Discourse.Model.extend({
// Whether to show replies directly below
showRepliesBelow: function() {
var reply_count, topic;
reply_count = this.get('reply_count');
var reply_count = this.get('reply_count');
// We don't show replies if there aren't any
if (reply_count === 0) return false;
@ -341,7 +340,7 @@ Discourse.Post = Discourse.Model.extend({
if (reply_count > 1) return true;
// If we have *exactly* one reply, we have to consider if it's directly below us
topic = this.get('topic');
var topic = this.get('topic');
return !topic.isReplyDirectlyBelow(this);
}.property('reply_count'),
@ -377,11 +376,12 @@ Discourse.Post.reopenClass({
return result;
},
deleteMany: function(posts) {
deleteMany: function(selectedPosts, selectedReplies) {
return Discourse.ajax("/posts/destroy_many", {
type: 'DELETE',
data: {
post_ids: posts.map(function(p) { return p.get('id'); })
post_ids: selectedPosts.map(function(p) { return p.get('id'); }),
reply_post_ids: selectedReplies.map(function(p) { return p.get('id'); })
}
});
},

View file

@ -32,7 +32,10 @@
</div>
<div class='topic-body span14'>
<button {{action selectPost this}} {{bindAttr class=":post-select controller.multiSelect::hidden"}}>{{view.selectText}}</button>
<div {{bindAttr class=":select-posts controller.multiSelect::hidden"}}>
<button {{action toggledSelectedPostReplies this}} {{bindAttr class="view.canSelectReplies::hidden"}}>{{i18n topic.multi_select.select_replies}}</button>
<button {{action toggledSelectedPost this}} class="select-post">{{view.selectPostText}}</button>
</div>
<div {{bindAttr class="showUserReplyTab:avoid-tab view.repliesShown::bottom-round :contents :regular view.extraClass"}}>
{{#unless controller.multiSelect}}

View file

@ -29,17 +29,20 @@ Discourse.PostView = Discourse.GroupedView.extend({
mouseUp: function(e) {
if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) {
this.get('controller').selectPost(this.get('post'));
this.get('controller').toggledSelectedPost(this.get('post'));
}
},
selected: function() {
var selectedPosts = this.get('controller.selectedPosts');
if (!selectedPosts) return false;
return selectedPosts.contains(this.get('post'));
return this.get('controller').postSelected(this.get('post'));
}.property('controller.selectedPostsCount'),
selectText: function() {
canSelectReplies: function() {
if (this.get('post.reply_count') === 0) { return false; }
return !this.get('selected');
}.property('post.reply_count', 'selected'),
selectPostText: function() {
return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select');
}.property('selected', 'controller.selectedPostsCount'),

View file

@ -500,9 +500,11 @@
}
&.selected {
article.boxed {
.post-select {
background-color: $blue;
color: $white;
.select-posts {
button.select-post {
background-color: $blue;
color: $white;
}
}
.topic-body {
.contents {
@ -519,20 +521,23 @@
font-size: 16px;
line-height: 20px;
.post-select {
@include border-radius-all(4px);
background-color: $light_gray;
border-top: 1px solid $white;
border-left: 1px solid $white;
border-bottom: 1px solid $gray;
border-right: 1px solid $gray;
color: $darkish_gray;
top: 4px;
.select-posts {
position: absolute;
right: 5px;
font-size: 12px;
padding: 2px 5px;
z-index: 490;
top: 4px;
button {
@include border-radius-all(4px);
background-color: $light_gray;
border-top: 1px solid $white;
border-left: 1px solid $white;
border-bottom: 1px solid $gray;
border-right: 1px solid $gray;
color: $darkish_gray;
font-size: 12px;
padding: 2px 5px;
}
}
img {

View file

@ -191,6 +191,16 @@ class ApplicationController < ActionController::Base
user
end
def post_ids_including_replies
post_ids = params[:post_ids].map {|p| p.to_i}
if params[:reply_post_ids]
post_ids << PostReply.where(post_id: params[:reply_post_ids].map {|p| p.to_i}).pluck(:reply_id)
post_ids.flatten!
post_ids.uniq!
end
post_ids
end
private
def preload_anonymous_data

View file

@ -150,10 +150,11 @@ class PostsController < ApplicationController
params.require(:post_ids)
posts = Post.where(id: params[:post_ids])
posts = Post.where(id: post_ids_including_replies)
raise Discourse::InvalidParameters.new(:post_ids) if posts.blank?
# Make sure we can delete the posts
posts.each {|p| guardian.ensure_can_delete!(p) }
Post.transaction do

View file

@ -244,7 +244,7 @@ class TopicsController < ApplicationController
topic = Topic.where(id: params[:topic_id]).first
guardian.ensure_can_move_posts!(topic)
dest_topic = move_post_to_destination(topic)
dest_topic = move_posts_to_destination(topic)
render_topic_changes(dest_topic)
end
@ -333,12 +333,12 @@ class TopicsController < ApplicationController
private
def move_post_to_destination(topic)
def move_posts_to_destination(topic)
args = {}
args[:title] = params[:title] if params[:title].present?
args[:destination_topic_id] = params[:destination_topic_id].to_i if params[:destination_topic_id].present?
topic.move_posts(current_user, params[:post_ids].map {|p| p.to_i}, args)
topic.move_posts(current_user, post_ids_including_replies, args)
end
end

View file

@ -92,6 +92,7 @@ predef:
- find
- sinon
- controllerFor
- testController
- Favcount
browser: true # true if the standard browser globals should be predefined

View file

@ -763,6 +763,7 @@ en:
multi_select:
select: 'select'
selected: 'selected ({{count}})'
select_replies: 'select +replies'
delete: delete selected
cancel: cancel selecting
description:

View file

@ -163,10 +163,10 @@ describe PostsController do
let!(:poster) { log_in(:moderator) }
let!(:post1) { Fabricate(:post, user: poster, post_number: 2) }
let!(:post2) { Fabricate(:post, topic_id: post1.topic_id, user: poster, post_number: 3) }
let!(:post2) { Fabricate(:post, topic_id: post1.topic_id, user: poster, post_number: 3, reply_to_post_number: post1.post_number) }
it "raises invalid parameters no post_ids" do
lambda { xhr :delete, :destroy_many }.should raise_error(ActionController::ParameterMissing)
lambda { xhr :delete, :destroy_many }.should raise_error(ActionController::ParameterMissing)
end
it "raises invalid parameters with missing ids" do
@ -189,6 +189,19 @@ describe PostsController do
xhr :delete, :destroy_many, post_ids: [post1.id, post2.id]
end
describe "can delete replies" do
before do
PostReply.create(post_id: post1.id, reply_id: post2.id)
end
it "deletes the post and the reply to it" do
Post.any_instance.expects(:destroy).twice
xhr :delete, :destroy_many, post_ids: [post1.id], reply_post_ids: [post1.id]
end
end
end
end

View file

@ -93,6 +93,27 @@ describe TopicsController do
end
end
describe "moving replied posts" do
let!(:user) { log_in(:moderator) }
let!(:p1) { Fabricate(:post, user: user) }
let!(:topic) { p1.topic }
let!(:p2) { Fabricate(:post, topic: topic, user: user, reply_to_post_number: p1.post_number ) }
context 'success' do
before do
PostReply.create(post_id: p1.id, reply_id: p2.id)
end
it "moves the child posts too" do
Topic.any_instance.expects(:move_posts).with(user, [p1.id, p2.id], title: 'blah').returns(topic)
xhr :post, :move_posts, topic_id: topic.id, title: 'blah', post_ids: [p1.id], reply_post_ids: [p1.id]
end
end
end
describe 'moving to an existing topic' do
let!(:user) { log_in(:moderator) }
let(:p1) { Fabricate(:post, user: user) }

View file

@ -7,7 +7,7 @@ var avatarSelector = Em.Object.create({
module("Discourse.AvatarSelectorController");
test("avatarTemplate", function() {
var avatarSelectorController = controllerFor("avatarSelector");
var avatarSelectorController = testController(Discourse.AvatarSelectorController);
avatarSelectorController.setProperties(avatarSelector);
equal(avatarSelectorController.get("avatarTemplate"),

View file

@ -16,7 +16,7 @@ var buildAdminUser = function(args) {
module("Discourse.FlagController canDeleteSpammer");
test("canDeleteSpammer not staff", function(){
var flagController = controllerFor('flag', buildPost());
var flagController = testController(Discourse.FlagController, buildPost());
this.stub(Discourse.User, 'currentProp').withArgs('staff').returns(false);
flagController.set('selected', Discourse.PostActionType.create({name_key: 'spam'}));
equal(flagController.get('canDeleteSpammer'), false, 'false if current user is not staff');
@ -29,7 +29,7 @@ var canDeleteSpammer = function(test, postActionType, expected, testName) {
test("canDeleteSpammer spam not selected", function(){
this.stub(Discourse.User, 'currentProp').withArgs('staff').returns(true);
this.flagController = controllerFor('flag', buildPost());
this.flagController = testController(Discourse.FlagController, buildPost());
this.flagController.set('userDetails', buildAdminUser({can_delete_all_posts: true, can_be_deleted: true}));
canDeleteSpammer(this, 'off_topic', false, 'false if current user is staff, but selected is off_topic');
canDeleteSpammer(this, 'inappropriate', false, 'false if current user is staff, but selected is inappropriate');
@ -39,7 +39,7 @@ test("canDeleteSpammer spam not selected", function(){
test("canDeleteSpammer spam selected", function(){
this.stub(Discourse.User, 'currentProp').withArgs('staff').returns(true);
this.flagController = controllerFor('flag', buildPost());
this.flagController = testController(Discourse.FlagController, buildPost());
this.flagController.set('userDetails', buildAdminUser({can_delete_all_posts: true, can_be_deleted: true}));
canDeleteSpammer(this, 'spam', true, 'true if current user is staff, selected is spam, posts and user can be deleted');

View file

@ -1,22 +1,19 @@
module("Discourse.TopicController");
var topic = Discourse.Topic.create({
title: "Qunit Test Topic",
participants: [
{id: 1234,
post_count: 4,
username: "eviltrout"}
]
});
module("Discourse.TopicController", {
setup: function() {
this.topicController = controllerFor('topic', topic);
}
});
var buildTopic = function() {
return Discourse.Topic.create({
title: "Qunit Test Topic",
participants: [
{id: 1234,
post_count: 4,
username: "eviltrout"}
]
});
};
test("editingMode", function() {
var topicController = this.topicController;
var topic = buildTopic(),
topicController = testController(Discourse.TopicController, topic);
ok(!topicController.get('editingTopic'), "we are not editing by default");
@ -32,4 +29,89 @@ test("editingMode", function() {
topicController.cancelEditingTopic();
ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value");
});
});
test("toggledSelectedPost", function() {
var tc = testController(Discourse.TopicController, buildTopic()),
post = Discourse.Post.create({id: 123, post_number: 2}),
postStream = tc.get('postStream');
postStream.appendPost(post);
postStream.appendPost(Discourse.Post.create({id: 124, post_number: 3}));
blank(tc.get('selectedPosts'), "there are no selected posts by default");
equal(tc.get('selectedPostsCount'), 0, "there is a selected post count of 0");
ok(!tc.postSelected(post), "the post is not selected by default");
tc.toggledSelectedPost(post);
present(tc.get('selectedPosts'), "there is a selectedPosts collection");
equal(tc.get('selectedPostsCount'), 1, "there is a selected post now");
ok(tc.postSelected(post), "the post is now selected");
tc.toggledSelectedPost(post);
ok(!tc.postSelected(post), "the post is no longer selected");
});
test("selectAll", function() {
var tc = testController(Discourse.TopicController, buildTopic()),
post = Discourse.Post.create({id: 123, post_number: 2}),
postStream = tc.get('postStream');
postStream.appendPost(post);
ok(!tc.postSelected(post), "the post is not selected by default");
tc.selectAll();
ok(tc.postSelected(post), "the post is now selected");
ok(tc.get('allPostsSelected'), "all posts are selected");
tc.deselectAll();
ok(!tc.postSelected(post), "the post is deselected again");
ok(!tc.get('allPostsSelected'), "all posts are not selected");
});
test("Automating setting of allPostsSelected", function() {
var topic = buildTopic(),
tc = testController(Discourse.TopicController, topic),
post = Discourse.Post.create({id: 123, post_number: 2}),
postStream = tc.get('postStream');
topic.set('posts_count', 1);
postStream.appendPost(post);
ok(!tc.get('allPostsSelected'), "all posts are not selected by default");
tc.toggledSelectedPost(post);
ok(tc.get('allPostsSelected'), "all posts are selected if we select the only post");
tc.toggledSelectedPost(post);
ok(!tc.get('allPostsSelected'), "the posts are no longer automatically selected");
});
test("Select Replies when present", function() {
var topic = buildTopic(),
tc = testController(Discourse.TopicController, topic),
p1 = Discourse.Post.create({id: 1, post_number: 1, reply_count: 1}),
p2 = Discourse.Post.create({id: 2, post_number: 2}),
p3 = Discourse.Post.create({id: 2, post_number: 3, reply_to_post_number: 1}),
postStream = tc.get('postStream');
ok(!tc.postSelected(p3), "replies are not selected by default");
tc.toggledSelectedPostReplies(p1);
ok(tc.postSelected(p1), "it selects the post");
ok(!tc.postSelected(p2), "it doesn't select a post that's not a reply");
ok(tc.postSelected(p3), "it selects a post that is a reply");
equal(tc.get('selectedPostsCount'), 2, "it has a selected posts count of two");
// If we deselected the post whose replies are selected...
tc.toggledSelectedPost(p1);
ok(!tc.postSelected(p1), "it deselects the post");
ok(!tc.postSelected(p3), "it deselects the replies too");
// If we deselect a reply, it should deselect the parent's replies selected attribute. Weird but what else would make sense?
tc.toggledSelectedPostReplies(p1);
tc.toggledSelectedPost(p3);
ok(tc.postSelected(p1), "the post stays selected");
ok(!tc.postSelected(p3), "it deselects the replies too");
});

View file

@ -14,6 +14,10 @@ function integration(name) {
});
}
function testController(klass, model) {
return klass.create({model: model, container: Discourse.__container__});
}
function controllerFor(controller, model) {
var controller = Discourse.__container__.lookup('controller:' + controller);
if (model) { controller.set('model', model ); }

View file

@ -122,6 +122,7 @@ var jsHintOpts = {
"console",
"alert",
"controllerFor",
"testController",
"containsInstance",
"deepEqual",
"notEqual",