diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js
index 29e0fd5feb6..8815445c54a 100644
--- a/app/assets/javascripts/discourse/controllers/topic_controller.js
+++ b/app/assets/javascripts/discourse/controllers/topic_controller.js
@@ -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() {
diff --git a/app/assets/javascripts/discourse/mixins/selected_posts_count.js b/app/assets/javascripts/discourse/mixins/selected_posts_count.js
index a42c487564d..38425c9408b 100644
--- a/app/assets/javascripts/discourse/mixins/selected_posts_count.js
+++ b/app/assets/javascripts/discourse/mixins/selected_posts_count.js
@@ -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')
});
diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js
index 9a567a9f56f..9ba308645c6 100644
--- a/app/assets/javascripts/discourse/models/topic.js
+++ b/app/assets/javascripts/discourse/models/topic.js
@@ -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',
diff --git a/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars b/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars
index 537058c9eb9..8426f7bb936 100644
--- a/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars
@@ -1,11 +1,21 @@
{{countI18n topic.multi_select.description countBinding="controller.selectedPostsCount"}}
+{{#if canSelectAll}}
+ select all
+{{/if}}
+
+{{#if canDeselectAll}}
+ deselect all
+{{/if}}
+
{{#if canDeleteSelected}}
{{/if}}
-{{#if canMoveSelected}}
+{{#if canSplitTopic}}
+{{/if}}
+{{#if canMergeTopic}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
index c0909bf1479..64d5d7e07ba 100644
--- a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
@@ -28,7 +28,7 @@
{{else}}
{{/if}}
-
+
{{#if content.archived}}
@@ -36,7 +36,7 @@
{{else}}
{{/if}}
-
+
{{#if content.visible}}
diff --git a/app/assets/javascripts/discourse/views/modal/merge_topic_view.js b/app/assets/javascripts/discourse/views/modal/merge_topic_view.js
index 4ae99526ba8..eb9a5cdf114 100644
--- a/app/assets/javascripts/discourse/views/modal/merge_topic_view.js
+++ b/app/assets/javascripts/discourse/views/modal/merge_topic_view.js
@@ -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();
diff --git a/app/assets/javascripts/discourse/views/post_view.js b/app/assets/javascripts/discourse/views/post_view.js
index f320a61a4e4..76fae5539ca 100644
--- a/app/assets/javascripts/discourse/views/post_view.js
+++ b/app/assets/javascripts/discourse/views/post_view.js
@@ -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');
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 145e19939ac..8875599bdb3 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index e8c8b4e0a79..c26799d47fd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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+/}
diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb
index a7409a1d797..d25f9c7ae1e 100644
--- a/spec/controllers/topics_controller_spec.rb
+++ b/spec/controllers/topics_controller_spec.rb
@@ -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' }