Support for "Select All / Deselect All" while selecting posts to merge / delete.

This commit is contained in:
Robin Ward 2013-05-16 15:55:14 -04:00
parent 7daca77443
commit a80ec535a3
10 changed files with 177 additions and 41 deletions

View File

@ -15,41 +15,48 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
loadingBelow: false,
loadingAbove: false,
needs: ['header', 'modal', 'composer', 'quoteButton'],
allPostsSelected: false,
selectedPosts: new Em.Set(),
selectedPosts: function() {
var posts = this.get('content.posts');
if (!posts) return null;
return posts.filterProperty('selected');
}.property('content.posts.@each.selected'),
canMoveSelected: function() {
if (!this.get('content.can_move_posts')) return false;
canMergeTopic: function() {
if (!this.get('can_move_posts')) return false;
return (this.get('selectedPostsCount') > 0);
}.property('canDeleteSelected'),
}.property('selectedPostsCount'),
canSplitTopic: function() {
if (!this.get('can_move_posts')) return false;
if (this.get('allPostsSelected')) return false;
return (this.get('selectedPostsCount') > 0);
}.property('selectedPostsCount'),
canSelectAll: Em.computed.not('allPostsSelected'),
canDeselectAll: function () {
if (this.get('selectedPostsCount') > 0) return true;
if (this.get('allPostsSelected')) return true;
}.property('selectedPostsCount', 'allPostsSelected'),
canDeleteSelected: function() {
var selectedPosts = this.get('selectedPosts');
if (this.get('allPostsSelected')) return true;
if (this.get('selectedPostsCount') === 0) return false;
var canDelete = true;
selectedPosts.each(function(p) {
selectedPosts.forEach(function(p) {
if (!p.get('can_delete')) {
canDelete = false;
return false;
}
});
return canDelete;
}.property('selectedPosts'),
}.property('selectedPostsCount'),
multiSelectChanged: function() {
// Deselect all posts when multi select is turned off
if (!this.get('multiSelect')) {
var posts = this.get('content.posts');
if (posts) {
posts.forEach(function(p) {
p.set('selected', false);
});
}
this.deselectAll();
}
}.observes('multiSelect'),
@ -61,7 +68,32 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
}.property('content.loaded', 'currentPost', 'content.filtered_posts_count'),
selectPost: function(post) {
post.toggleProperty('selected');
var selectedPosts = this.get('selectedPosts');
if (selectedPosts.contains(post)) {
selectedPosts.removeObject(post);
this.set('allPostsSelected', 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');
}
}
},
selectAll: function() {
var posts = this.get('posts');
var selectedPosts = this.get('selectedPosts');
if (posts) {
selectedPosts.addObjects(posts);
}
this.set('allPostsSelected', true);
},
deselectAll: function() {
this.get('selectedPosts').clear();
this.set('allPostsSelected', false);
},
toggleMultiSelect: function() {
@ -90,14 +122,21 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
modalController.show(Discourse.MergeTopicView.create({
topicController: this,
topic: this.get('content'),
allPostsSelected: this.get('allPostsSelected'),
selectedPosts: this.get('selectedPosts')
}));
},
deleteSelected: function() {
var topicController = this;
return bootbox.confirm(Em.String.i18n("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) {
bootbox.confirm(Em.String.i18n("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();
}
var selectedPosts = topicController.get('selectedPosts');
Discourse.Post.deleteMany(selectedPosts);
topicController.get('content.posts').removeObjects(selectedPosts);
@ -245,7 +284,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
});
}.observes('postFilters'),
deleteTopic: function(e) {
deleteTopic: function() {
var topicController = this;
this.unsubscribe();
this.get('content').destroy().then(function() {

View File

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

View File

@ -449,6 +449,17 @@ Discourse.Topic.reopenClass({
});
},
mergeTopic: function(topicId, destinationTopicId) {
var promise = Discourse.ajax("/t/" + topicId + "/merge-topic", {
type: 'POST',
data: {destination_topic_id: destinationTopicId}
}).then(function (result) {
if (result.success) return result;
promise.reject();
});
return promise;
},
movePosts: function(topicId, opts) {
var promise = Discourse.ajax("/t/" + topicId + "/move-posts", {
type: 'POST',

View File

@ -1,11 +1,21 @@
<p>{{countI18n topic.multi_select.description countBinding="controller.selectedPostsCount"}}</p>
{{#if canSelectAll}}
<p><a href='#' {{action selectAll}}>select all</a></p>
{{/if}}
{{#if canDeselectAll}}
<p><a href='#' {{action deselectAll}}>deselect all</a></p>
{{/if}}
{{#if canDeleteSelected}}
<button class='btn' {{action deleteSelected}}><i class='icon icon-trash'></i> {{i18n topic.multi_select.delete}}</button>
{{/if}}
{{#if canMoveSelected}}
{{#if canSplitTopic}}
<button class='btn' {{action splitTopic}}><i class='icon icon-move'></i> {{i18n topic.split_topic.action}}</button>
{{/if}}
{{#if canMergeTopic}}
<button class='btn' {{action mergeTopic}}><i class='icon icon-move'></i> {{i18n topic.merge_topic.action}}}</button>
{{/if}}

View File

@ -28,7 +28,7 @@
{{else}}
<button {{action togglePinned}} class='btn btn-admin'><i class='icon-pushpin'></i> {{i18n topic.actions.pin}}</button>
{{/if}}
</li>
</li>
<li>
{{#if content.archived}}
@ -36,7 +36,7 @@
{{else}}
<button {{action toggleArchived}} class='btn btn-admin'><i class='icon-folder-close'></i> {{i18n topic.actions.archive}}</button>
{{/if}}
</li>
</li>
<li>
{{#if content.visible}}

View File

@ -23,13 +23,20 @@ Discourse.MergeTopicView = Discourse.ModalBodyView.extend(Discourse.SelectedPost
movePostsToExistingTopic: function() {
this.set('saving', true);
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
var moveSelectedView = this;
Discourse.Topic.movePosts(this.get('topic.id'), {
destination_topic_id: this.get('selectedTopicId'),
post_ids: postIds
}).then(function(result) {
var promise = null;
if (this.get('allPostsSelected')) {
promise = Discourse.Topic.mergeTopic(this.get('topic.id'), this.get('selectedTopicId'));
} else {
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
promise = Discourse.Topic.movePosts(this.get('topic.id'), {
destination_topic_id: this.get('selectedTopicId'),
post_ids: postIds
});
}
promise.then(function(result) {
// Posts moved
$('#discourse-modal').modal('hide');
moveSelectedView.get('topicController').toggleMultiSelect();

View File

@ -11,14 +11,14 @@ Discourse.PostView = Discourse.View.extend({
templateName: 'post',
classNameBindings: ['post.lastPost',
'postTypeClass',
'post.selected',
'selected',
'post.hidden:hidden',
'post.deleted_at:deleted',
'parentPost:replies-above'],
postBinding: 'content',
// TODO really we should do something cleaner here... this makes it work in debug but feels really messy
screenTrack: (function() {
screenTrack: function() {
var parentView = this.get('parentView');
var screenTrack = null;
while (parentView && !screenTrack) {
@ -26,17 +26,17 @@ Discourse.PostView = Discourse.View.extend({
parentView = parentView.get('parentView');
}
return screenTrack;
}).property('parentView'),
}.property('parentView'),
postTypeClass: (function() {
postTypeClass: function() {
return this.get('post.post_type') === Discourse.get('site.post_types.moderator_action') ? 'moderator' : 'regular';
}).property('post.post_type'),
}.property('post.post_type'),
// If the cooked content changed, add the quote controls
cookedChanged: (function() {
cookedChanged: function() {
var postView = this;
Em.run.next(function() { postView.insertQuoteControls(); });
}).observes('post.cooked'),
}.observes('post.cooked'),
init: function() {
this._super();
@ -45,13 +45,19 @@ Discourse.PostView = Discourse.View.extend({
mouseUp: function(e) {
if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) {
this.toggleProperty('post.selected');
this.get('controller').selectPost(this.get('post'));
}
},
selected: function() {
var selectedPosts = this.get('controller.selectedPosts');
if (!selectedPosts) return false;
return selectedPosts.contains(this.get('post'));
}.property('controller.selectedPostsCount'),
selectText: function() {
return this.get('post.selected') ? Em.String.i18n('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : Em.String.i18n('topic.multi_select.select');
}.property('post.selected', 'controller.selectedPostsCount'),
return this.get('selected') ? Em.String.i18n('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : Em.String.i18n('topic.multi_select.select');
}.property('selected', 'controller.selectedPostsCount'),
repliesHidden: function() {
return !this.get('repliesShown');

View File

@ -15,6 +15,7 @@ class TopicsController < ApplicationController
:unmute,
:set_notifications,
:move_posts,
:merge_topic,
:clear_pin,
:autoclose]
@ -137,6 +138,20 @@ class TopicsController < ApplicationController
render json: success_json
end
def merge_topic
requires_parameters(:destination_topic_id)
topic = Topic.where(id: params[:topic_id]).first
guardian.ensure_can_move_posts!(topic)
dest_topic = topic.move_posts(current_user, topic.posts.pluck(:id), destination_topic_id: params[:destination_topic_id].to_i)
if dest_topic.present?
render json: {success: true, url: dest_topic.relative_url}
else
render json: {success: false}
end
end
def move_posts
requires_parameters(:post_ids)
@ -153,7 +168,6 @@ class TopicsController < ApplicationController
else
render json: {success: false}
end
end
def clear_pin

View File

@ -212,6 +212,7 @@ Discourse::Application.routes.draw do
post 't/:topic_id/timings' => 'topics#timings', constraints: {topic_id: /\d+/}
post 't/:topic_id/invite' => 'topics#invite', constraints: {topic_id: /\d+/}
post 't/:topic_id/move-posts' => 'topics#move_posts', constraints: {topic_id: /\d+/}
post 't/:topic_id/merge-topic' => 'topics#merge_topic', constraints: {topic_id: /\d+/}
delete 't/:topic_id/timings' => 'topics#destroy_timings', constraints: {topic_id: /\d+/}
post 't/:topic_id/notifications' => 'topics#set_notifications' , constraints: {topic_id: /\d+/}

View File

@ -2,6 +2,8 @@ require 'spec_helper'
describe TopicsController do
context 'move_posts' do
it 'needs you to be logged in' do
lambda { xhr :post, :move_posts, topic_id: 111, title: 'blah', post_ids: [1,2,3] }.should raise_error(Discourse::NotLoggedIn)
@ -95,6 +97,49 @@ describe TopicsController do
end
end
context "merge_topic" do
it 'needs you to be logged in' do
lambda { xhr :post, :merge_topic, topic_id: 111, destination_topic_id: 345 }.should raise_error(Discourse::NotLoggedIn)
end
describe 'moving to a new topic' do
let!(:user) { log_in(:moderator) }
let(:p1) { Fabricate(:post, user: user) }
let(:topic) { p1.topic }
it "raises an error without destination_topic_id" do
lambda { xhr :post, :merge_topic, topic_id: topic.id }.should raise_error(Discourse::InvalidParameters)
end
it "raises an error when the user doesn't have permission to merge" do
Guardian.any_instance.expects(:can_move_posts?).returns(false)
xhr :post, :merge_topic, topic_id: 111, destination_topic_id: 345
response.should be_forbidden
end
let(:dest_topic) { Fabricate(:topic) }
context 'moves all the posts to the destination topic' do
let(:p2) { Fabricate(:post, user: user) }
before do
Topic.any_instance.expects(:move_posts).with(user, [p1.id], destination_topic_id: dest_topic.id).returns(topic)
xhr :post, :merge_topic, topic_id: topic.id, destination_topic_id: dest_topic.id
end
it "returns success" do
response.should be_success
result = ::JSON.parse(response.body)
result['success'].should be_true
result['url'].should be_present
end
end
end
end
context 'similar_to' do
let(:title) { 'this title is long enough to search for' }