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' }