diff --git a/app/assets/javascripts/discourse/controllers/change-owner.js.es6 b/app/assets/javascripts/discourse/controllers/change-owner.js.es6 index 84c323b88a6..3bb3c10ca2d 100644 --- a/app/assets/javascripts/discourse/controllers/change-owner.js.es6 +++ b/app/assets/javascripts/discourse/controllers/change-owner.js.es6 @@ -1,55 +1,54 @@ -import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; -import ModalFunctionality from 'discourse/mixins/modal-functionality'; -import DiscourseURL from 'discourse/lib/url'; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import DiscourseURL from "discourse/lib/url"; +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Controller.extend(ModalFunctionality, { + topicController: Ember.inject.controller("topic"), -// Modal related to changing the ownership of posts -export default Ember.Controller.extend(SelectedPostsCount, ModalFunctionality, { - topicController: Ember.inject.controller('topic'), - selectedPosts: Em.computed.alias('topicController.selectedPosts'), saving: false, new_user: null, - buttonDisabled: function() { - if (this.get('saving')) return true; - return Ember.isEmpty(this.get('new_user')); - }.property('saving', 'new_user'), + selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"), + selectedPostsUsername: Ember.computed.alias("topicController.selectedPostsUsername"), - buttonTitle: function() { - if (this.get('saving')) return I18n.t('saving'); - return I18n.t('topic.change_owner.action'); - }.property('saving'), + @computed("saving", "new_user") + buttonDisabled(saving, newUser) { + return saving || Ember.isEmpty(newUser); + }, - onShow: function() { + @computed("saving") + buttonTitle(saving) { + return saving ? I18n.t("saving") : I18n.t("topic.change_owner.action"); + }, + + onShow() { this.setProperties({ saving: false, - new_user: '' + new_user: "" }); }, actions: { - changeOwnershipOfPosts: function() { - this.set('saving', true); + changeOwnershipOfPosts() { + this.set("saving", true); - var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), - self = this, - saveOpts = { - post_ids: postIds, - username: this.get('new_user') - }; + const options = { + post_ids: this.get("topicController.selectedPostIds"), + username: this.get("new_user"), + }; - Discourse.Topic.changeOwners(this.get('topicController.model.id'), saveOpts).then(function() { - // success - self.send('closeModal'); - self.get('topicController').send('deselectAll'); - if (self.get('topicController.multiSelect')) { - self.get('topicController').send('toggleMultiSelect'); + Discourse.Topic.changeOwners(this.get("topicController.model.id"), options).then(() => { + this.send("closeModal"); + this.get("topicController").send("deselectAll"); + if (this.get("topicController.multiSelect")) { + this.get("topicController").send("toggleMultiSelect"); } - Em.run.next(() => { DiscourseURL.routeTo(self.get("topicController.model.url")); }); - }, function() { - // failure - self.flash(I18n.t('topic.change_owner.error'), 'alert-error'); - self.set('saving', false); + Ember.run.next(() => DiscourseURL.routeTo(this.get("topicController.model.url"))); + }, () => { + this.flash(I18n.t("topic.change_owner.error"), "alert-error"); + this.set("saving", false); }); + return false; } } diff --git a/app/assets/javascripts/discourse/controllers/merge-topic.js.es6 b/app/assets/javascripts/discourse/controllers/merge-topic.js.es6 index 34bfa8ba34e..6fe4f71e850 100644 --- a/app/assets/javascripts/discourse/controllers/merge-topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/merge-topic.js.es6 @@ -1,64 +1,53 @@ -import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; -import ModalFunctionality from 'discourse/mixins/modal-functionality'; -import { movePosts, mergeTopic } from 'discourse/models/topic'; -import DiscourseURL from 'discourse/lib/url'; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { movePosts, mergeTopic } from "discourse/models/topic"; +import DiscourseURL from "discourse/lib/url"; +import computed from "ember-addons/ember-computed-decorators"; -// Modal related to merging of topics -export default Ember.Controller.extend(SelectedPostsCount, ModalFunctionality, { - topicController: Ember.inject.controller('topic'), +export default Ember.Controller.extend(ModalFunctionality, { + topicController: Ember.inject.controller("topic"), saving: false, selectedTopicId: null, - selectedPosts: Em.computed.alias('topicController.selectedPosts'), - selectedReplies: Em.computed.alias('topicController.selectedReplies'), - allPostsSelected: Em.computed.alias('topicController.allPostsSelected'), + selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"), - buttonDisabled: function() { - if (this.get('saving')) return true; - return Ember.isEmpty(this.get('selectedTopicId')); - }.property('selectedTopicId', 'saving'), + @computed("saving", "selectedTopicId") + buttonDisabled(saving, selectedTopicId) { + return saving || Ember.isEmpty(selectedTopicId); + }, - buttonTitle: function() { - if (this.get('saving')) return I18n.t('saving'); - return I18n.t('topic.merge_topic.title'); - }.property('saving'), + @computed("saving") + buttonTitle(saving) { + return saving ? I18n.t("saving") : I18n.t("topic.merge_topic.title"); + }, onShow() { - this.set('modal.modalClass', 'split-modal'); + this.set("modal.modalClass", "split-modal"); }, actions: { movePostsToExistingTopic() { - const topicId = this.get('model.id'); + const topicId = this.get("model.id"); - this.set('saving', true); + this.set("saving", true); - let promise = null; - if (this.get('allPostsSelected')) { - promise = mergeTopic(topicId, this.get('selectedTopicId')); - } else { - const postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }); - const replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }); - - promise = movePosts(topicId, { - destination_topic_id: this.get('selectedTopicId'), - post_ids: postIds, - reply_post_ids: replyPostIds + let promise = this.get("topicController.selectedAllPosts") ? + mergeTopic(topicId, this.get("selectedTopicId")) : + movePosts(topicId, { + destination_topic_id: this.get("selectedTopicId"), + post_ids: this.get("topicController.selectedPostIds") }); - } - const self = this; - promise.then(function(result) { - // Posts moved - self.send('closeModal'); - self.get('topicController').send('toggleMultiSelect'); - Em.run.next(function() { DiscourseURL.routeTo(result.url); }); - }).catch(function() { - self.flash(I18n.t('topic.merge_topic.error')); - }).finally(function() { - self.set('saving', false); + promise.then(result => { + this.send("closeModal"); + this.get("topicController").send("toggleMultiSelect"); + Ember.run.next(() => DiscourseURL.routeTo(result.url)); + }).catch(() => { + this.flash(I18n.t("topic.merge_topic.error")); + }).finally(() => { + this.set("saving", false); }); + return false; } } diff --git a/app/assets/javascripts/discourse/controllers/split-topic.js.es6 b/app/assets/javascripts/discourse/controllers/split-topic.js.es6 index c8ea321570e..818205e703c 100644 --- a/app/assets/javascripts/discourse/controllers/split-topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/split-topic.js.es6 @@ -1,68 +1,58 @@ -import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; -import ModalFunctionality from 'discourse/mixins/modal-functionality'; -import { extractError } from 'discourse/lib/ajax-error'; -import { movePosts } from 'discourse/models/topic'; -import DiscourseURL from 'discourse/lib/url'; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { extractError } from "discourse/lib/ajax-error"; +import { movePosts } from "discourse/models/topic"; +import DiscourseURL from "discourse/lib/url"; +import { default as computed } from "ember-addons/ember-computed-decorators"; -// Modal related to auto closing of topics -export default Ember.Controller.extend(SelectedPostsCount, ModalFunctionality, { +export default Ember.Controller.extend(ModalFunctionality, { topicName: null, saving: false, categoryId: null, - topicController: Ember.inject.controller('topic'), - selectedPosts: Em.computed.alias('topicController.selectedPosts'), - selectedReplies: Em.computed.alias('topicController.selectedReplies'), - allPostsSelected: Em.computed.alias('topicController.allPostsSelected'), + topicController: Ember.inject.controller("topic"), + selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"), - buttonDisabled: function() { - if (this.get('saving')) return true; - return Ember.isEmpty(this.get('topicName')); - }.property('saving', 'topicName'), + @computed("saving", "topicName") + buttonDisabled(saving, topicName) { + return saving || Ember.isEmpty(topicName); + }, - buttonTitle: function() { - if (this.get('saving')) return I18n.t('saving'); - return I18n.t('topic.split_topic.action'); - }.property('saving'), + @computed("saving") + buttonTitle(saving) { + return saving ? I18n.t("saving") : I18n.t("topic.split_topic.action"); + }, onShow() { this.setProperties({ - 'modal.modalClass': 'split-modal', + "modal.modalClass": "split-modal", saving: false, categoryId: null, - topicName: '' + topicName: "" }); }, actions: { movePostsToNewTopic() { - this.set('saving', true); + this.set("saving", true); - const postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), - replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }), - self = this, - categoryId = this.get('categoryId'), - saveOpts = { - title: this.get('topicName'), - post_ids: postIds, - reply_post_ids: replyPostIds - }; + const options = { + title: this.get("topicName"), + post_ids: this.get("topicController.selectedPostIds"), + category_id: this.get("categoryId") + }; - if (!Ember.isNone(categoryId)) { saveOpts.category_id = categoryId; } - - movePosts(this.get('model.id'), saveOpts).then(function(result) { - // Posts moved - self.send('closeModal'); - self.get('topicController').send('toggleMultiSelect'); - Ember.run.next(function() { DiscourseURL.routeTo(result.url); }); - }).catch(function(xhr) { - self.flash(extractError(xhr, I18n.t('topic.split_topic.error'))); - }).finally(function() { - self.set('saving', false); + movePosts(this.get("model.id"), options).then(result => { + this.send("closeModal"); + this.get("topicController").send("toggleMultiSelect"); + Ember.run.next(() => DiscourseURL.routeTo(result.url)); + }).catch(xhr => { + this.flash(extractError(xhr, I18n.t("topic.split_topic.error"))); + }).finally(() => { + this.set("saving", false); }); + return false; } } - }); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index bc79faf6b0e..efd6f8e0ebd 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -1,27 +1,25 @@ import BufferedContent from 'discourse/mixins/buffered-content'; -import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; -import { spinnerHTML } from 'discourse/helpers/loading-spinner'; -import Topic from 'discourse/models/topic'; -import Quote from 'discourse/lib/quote'; -import { popupAjaxError } from 'discourse/lib/ajax-error'; -import computed from 'ember-addons/ember-computed-decorators'; import Composer from 'discourse/models/composer'; import DiscourseURL from 'discourse/lib/url'; import Post from 'discourse/models/post'; +import Quote from 'discourse/lib/quote'; +import QuoteState from 'discourse/lib/quote-state'; +import Topic from 'discourse/models/topic'; import debounce from 'discourse/lib/debounce'; import isElementInViewport from "discourse/lib/is-element-in-viewport"; -import QuoteState from 'discourse/lib/quote-state'; -import { userPath } from 'discourse/lib/url'; +import { ajax } from 'discourse/lib/ajax'; +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import { extractLinkMeta } from 'discourse/lib/render-topic-featured-link'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { spinnerHTML } from 'discourse/helpers/loading-spinner'; +import { userPath } from 'discourse/lib/url'; -export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { +export default Ember.Controller.extend(BufferedContent, { composer: Ember.inject.controller(), application: Ember.inject.controller(), multiSelect: false, - allPostsSelected: false, + selectedPostIds: null, editingTopic: false, - selectedPosts: null, - selectedReplies: null, queryParams: ['filter', 'username_filters'], loadedAllPosts: Ember.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'), enteredAt: null, @@ -36,29 +34,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { canRemoveTopicFeaturedLink: Ember.computed.and('canEditTopicFeaturedLink', 'buffered.featured_link'), updateQueryParams() { - const postStream = this.get('model.postStream'); - this.setProperties(postStream.get('streamFilters')); + this.setProperties(this.get('model.postStream.streamFilters')); }, - _titleChanged: function() { + @observes('model.title', 'category') + _titleChanged() { const title = this.get('model.title'); if (!Ember.isEmpty(title)) { - - // Note normally you don't have to trigger this, but topic titles can be updated - // and are sometimes lazily loaded. + // force update lazily loaded titles this.send('refreshTitle'); } - }.observes('model.title', 'category'), + }, @computed('site.mobileView', 'model.posts_count') showSelectedPostsAtBottom(mobileView, postsCount) { - return mobileView && (postsCount > 3); + return mobileView && postsCount > 3; }, - @computed('model.postStream.posts') - postsToRender() { - return this.capabilities.isAndroid ? this.get('model.postStream.posts') - : this.get('model.postStream.postsWithPlaceholders'); + @computed('model.postStream.posts', 'model.postStream.postsWithPlaceholders') + postsToRender(posts, postsWithPlaceholders) { + return this.capabilities.isAndroid ? posts : postsWithPlaceholders; }, @computed('model.postStream.loadingFilter') @@ -66,17 +61,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return this.capabilities.isAndroid && loading; }, - @computed('model') - pmPath(model) { - return this.currentUser && this.currentUser.pmPath(model); + pmPath(topic) { + return this.currentUser && this.currentUser.pmPath(topic); }, init() { this._super(); - this.set('selectedPosts', []); - this.set('selectedReplies', []); - this.set('quoteState', new QuoteState()); + this.setProperties({ + selectedPostIds: [], + quoteState: new QuoteState(), + }); }, showCategoryChooser: Ember.computed.not("model.isPrivateMessage"), @@ -89,22 +84,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { DiscourseURL.routeTo(url); }, - selectedQuery: function() { + @computed + selectedQuery() { return post => this.postSelected(post); - }.property(), + }, @computed('model.isPrivateMessage', 'model.category.id') canEditTopicFeaturedLink(isPrivateMessage, categoryId) { if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; } const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); - return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + return categoryIds === undefined || !categoryIds.length || categoryIds.includes(categoryId); }, @computed('model') featuredLinkDomain(topic) { - const meta = extractLinkMeta(topic); - return meta.domain; + return extractLinkMeta(topic).domain; }, @computed('model.isPrivateMessage') @@ -258,16 +253,15 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // Archive a PM (as opposed to archiving a topic) toggleArchiveMessage() { const topic = this.get('model'); + if (topic.get('archiving')) { return; } + const backToInbox = () => this.goToInbox(topic.get("inboxGroupName")); + if (topic.get('message_archived')) { - topic.moveToInbox().then(()=>{ - this.gotoInbox(topic.get("inboxGroupName")); - }); + topic.moveToInbox().then(backToInbox); } else { - topic.archiveMessage().then(()=>{ - this.gotoInbox(topic.get("inboxGroupName")); - }); + topic.archiveMessage().then(backToInbox); } }, @@ -275,10 +269,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { replyToPost(post) { const composerController = this.get('composer'); const topic = post ? post.get('topic') : this.get('model'); - const quoteState = this.get('quoteState'); const postStream = this.get('model.postStream'); + if (!postStream) return; + const quotedPost = postStream.findLoadedPost(quoteState.postId); const quotedText = Quote.build(quotedPost, quoteState.buffer); @@ -290,7 +285,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { composerController.set('content.composeState', Composer.OPEN); this.appEvents.trigger('composer:insert-block', quotedText.trim()); } else { - const opts = { action: Composer.REPLY, draftKey: topic.get('draft_key'), @@ -311,78 +305,94 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, recoverPost(post) { - // Recovering the first post recovers the topic instead - if (post.get('post_number') === 1) { - this.recoverTopic(); - return; - } - post.recover(); + post.get("post_number") === 1 ? this.recoverTopic() : this.recover(); }, deletePost(post) { - - // Deleting the first post deletes the topic if (post.get('post_number') === 1) { return this.deleteTopic(); } else if (!post.can_delete) { - // check if current user can delete post return false; } - const user = Discourse.User.current(), - replyCount = post.get('reply_count'), - self = this; + const user = this.currentUser; + const refresh = () => this.appEvents.trigger('post-stream:refresh'); + const hasReplies = post.get('reply_count') > 0; + const loadedPosts = this.get('model.postStream.posts'); - // If the user is staff and the post has replies, ask if they want to delete replies too. - if (user.get('staff') && replyCount > 0) { - bootbox.dialog(I18n.t("post.controls.delete_replies.confirm", {count: replyCount}), [ - {label: I18n.t("cancel"), - 'class': 'btn-danger right'}, - {label: I18n.t("post.controls.delete_replies.no_value"), + if (user.get('staff') && hasReplies) { + ajax(`/posts/${post.id}/reply-ids.json`).then(replies => { + const buttons = []; + + buttons.push({ + label: I18n.t('cancel'), + 'class': 'btn-danger right' + }); + + buttons.push({ + label: I18n.t('post.controls.delete_replies.just_the_post'), callback() { - post.destroy(user); - } - }, - {label: I18n.t("post.controls.delete_replies.yes_value"), - 'class': 'btn-primary', - callback() { - Discourse.Post.deleteMany([post], [post]); - self.get('model.postStream.posts').forEach(function (p) { - if (p === post || p.get('reply_to_post_number') === post.get('post_number')) { - p.setDeletedState(user); - } - }); + post.destroy(user) + .then(refresh) + .catch(error => { + popupAjaxError(error); + post.undoDeleteState(); + }); } + }); + + if (replies.some(r => r.level > 1)) { + buttons.push({ + label: I18n.t('post.controls.delete_replies.all_replies', { count: replies.length }), + callback() { + loadedPosts.forEach(p => (p === post || replies.some(r => r.id === p.id)) && p.setDeletedState(user)); + Post.deleteMany([post.id, ...replies.map(r => r.id)]) + .then(refresh) + .catch(popupAjaxError); + } + }); } - ]); - } else { - return post.destroy(user).then(() => { - this.appEvents.trigger('post-stream:refresh'); - }).catch(error => { - popupAjaxError(error); - post.undoDeleteState(); + + const directReplyIds = replies.filter(r => r.level === 1).map(r => r.id); + + buttons.push({ + label: I18n.t('post.controls.delete_replies.direct_replies', { count: directReplyIds.length }), + 'class': 'btn-primary', + callback() { + loadedPosts.forEach(p => (p === post || directReplyIds.includes(p.id)) && p.setDeletedState(user)); + Post.deleteMany([post.id, ...directReplyIds]) + .then(refresh) + .catch(popupAjaxError); + } + }); + + bootbox.dialog(I18n.t("post.controls.delete_replies.confirm"), buttons); }); + } else { + return post.destroy(user) + .then(refresh) + .catch(error => { + popupAjaxError(error); + post.undoDeleteState(); + }); } }, editPost(post) { - if (!Discourse.User.current()) { + if (!this.currentUser) { return bootbox.alert(I18n.t('post.controls.edit_anonymous')); - } - - // check if current user can edit post - if (!post.can_edit) { + } else if (!post.can_edit) { return false; } - const composer = this.get('composer'), - composerModel = composer.get('model'), - opts = { - post: post, - action: Composer.EDIT, - draftKey: post.get('topic.draft_key'), - draftSequence: post.get('topic.draft_sequence') - }; + const composer = this.get("composer"); + const composerModel = composer.get("model"); + const opts = { + post, + action: Composer.EDIT, + draftKey: post.get("topic.draft_key"), + draftSequence: post.get("topic.draft_sequence") + }; // Cancel and reopen the composer for the first post if (composerModel && (post.get('firstPost') || composerModel.get('editingFirstPost'))) { @@ -394,10 +404,8 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { toggleBookmark(post) { if (!this.currentUser) { - alert(I18n.t("bookmarks.not_bookmarked")); - return; - } - if (post) { + return bootbox.alert(I18n.t("bookmarks.not_bookmarked")); + } else if (post) { return post.toggleBookmark().catch(popupAjaxError); } else { return this.get("model").toggleBookmark().then(changedIds => { @@ -408,14 +416,16 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, jumpToIndex(index) { - this._jumpToPostId(this.get('model.postStream.stream')[index-1]); + this._jumpToPostId(this.get('model.postStream.stream')[index - 1]); }, jumpToPostPrompt() { const postText = prompt(I18n.t('topic.progress.jump_prompt_long')); if (postText === null) { return; } + const postNumber = parseInt(postText, 10); if (postNumber === 0) { return; } + this._jumpToPostId(this.get('model.postStream').findPostIdForPostNumber(postNumber)); }, @@ -428,6 +438,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { const closest = postStream.closestPostNumberFor(postNumber); postId = postStream.findPostIdForPostNumber(closest); } + this._jumpToPostId(postId); }, @@ -443,96 +454,51 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this._jumpToPostId(this.get('model.last_read_post_id')); }, - selectAll() { - const posts = this.get('model.postStream.posts'); - const selectedPosts = this.get('selectedPosts'); - if (posts) { - selectedPosts.addObjects(posts); - } - this.set('allPostsSelected', true); - this.appEvents.trigger('post-stream:refresh', { force: true }); - }, - - deselectAll() { - this.get('selectedPosts').clear(); - this.get('selectedReplies').clear(); - this.set('allPostsSelected', false); - this.appEvents.trigger('post-stream:refresh', { force: true }); - }, - - toggleParticipant(user) { - const postStream = this.get('model.postStream'); - postStream.toggleParticipant(Ember.get(user, 'username')).then(() => { - this.updateQueryParams(); - }); - }, - - editTopic() { - if (!this.get('model.details.can_edit')) return false; - - this.set('editingTopic', true); - return false; - }, - - cancelEditingTopic() { - this.set('editingTopic', false); - this.rollbackBuffer(); - }, - toggleMultiSelect() { this.toggleProperty('multiSelect'); this.appEvents.trigger('post-stream:refresh', { force: true }); }, - finishedEditingTopic() { - if (!this.get('editingTopic')) { return; } - - // save the modifications - const self = this, - props = this.get('buffered.buffer'); - - Topic.update(this.get('model'), props).then(function() { - // Note we roll back on success here because `update` saves - // the properties to the topic. - self.rollbackBuffer(); - self.set('editingTopic', false); - }).catch(popupAjaxError); + selectAll() { + this.set('selectedPostIds', [...this.get('model.postStream.stream')]); + this.appEvents.trigger('post-stream:refresh', { force: true }); }, - toggledSelectedPost(post) { - this.performTogglePost(post); + deselectAll() { + this.set('selectedPostIds', []); + this.appEvents.trigger('post-stream:refresh', { force: true }); }, - toggledSelectedPostReplies(post) { - const selectedReplies = this.get('selectedReplies'); - if (this.performTogglePost(post)) { - selectedReplies.addObject(post); - } else { - selectedReplies.removeObject(post); - } + togglePostSelection(post) { + const selected = this.get('selectedPostIds'); + selected.includes(post.id) ? selected.removeObject(post.id) : selected.addObject(post.id); + }, + + selectReplies(post) { + ajax(`/posts/${post.id}/reply-ids.json`).then(replies => { + const replyIds = replies.map(r => r.id); + this.get('selectedPostIds').pushObjects([post.id, ...replyIds]); + this.appEvents.trigger('post-stream:refresh', { force: true }); + }); + }, + + selectBelow(post) { + const stream = [...this.get('model.postStream.stream')]; + const below = stream.slice(stream.indexOf(post.id)); + this.get('selectedPostIds').pushObjects(below); this.appEvents.trigger('post-stream:refresh', { force: true }); }, deleteSelected() { + const user = this.currentUser; + bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), result => { if (result) { - // If all posts are selected, it's the same thing as deleting the topic - if (this.get('allPostsSelected')) { - return this.deleteTopic(); - } - - const selectedPosts = this.get('selectedPosts'); - const selectedReplies = this.get('selectedReplies'); - const postStream = this.get('model.postStream'); - - Discourse.Post.deleteMany(selectedPosts, selectedReplies); - postStream.get('posts').forEach(p => { - if (this.postSelected(p)) { - p.set('deleted_at', new Date()); - } - }); + if (this.get('selectedAllPosts')) return this.deleteTopic(); + Post.deleteMany(this.get('selectedPostIds')); + this.get('model.postStream.posts').forEach(p => this.postSelected(p) && p.setDeletedState(user)); this.send('toggleMultiSelect'); } }); @@ -541,13 +507,48 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { mergePosts() { bootbox.confirm(I18n.t("post.merge.confirm", { count: this.get('selectedPostsCount') }), result => { if (result) { - const selectedPosts = this.get('selectedPosts'); - Post.mergePosts(selectedPosts); + Post.mergePosts(this.get("selectedPostIds")); this.send('toggleMultiSelect'); } }); }, + changePostOwner(post) { + this.get("selectedPostIds").addObject(post.id); + this.send('changeOwner'); + }, + + toggleParticipant(user) { + this.get("model.postStream") + .toggleParticipant(user.get("username")) + .then(() => this.updateQueryParams); + }, + + editTopic() { + if (this.get('model.details.can_edit')) { + this.set('editingTopic', true); + } + return false; + }, + + cancelEditingTopic() { + this.set('editingTopic', false); + this.rollbackBuffer(); + }, + + finishedEditingTopic() { + if (!this.get('editingTopic')) { return; } + + // save the modifications + const props = this.get('buffered.buffer'); + + Topic.update(this.get('model'), props).then(() => { + // We roll back on success here because `update` saves the properties to the topic + this.rollbackBuffer(); + this.set('editingTopic', false); + }).catch(popupAjaxError); + }, + expandHidden(post) { return post.expandHidden(); }, @@ -665,13 +666,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, retryLoading() { - const self = this; - self.set('retrying', true); - this.get('model.postStream').refresh().then(function() { - self.set('retrying', false); - }, function() { - self.set('retrying', false); - }); + this.set("retrying", true); + const rollback = () => this.set("retrying", false); + this.get("model.postStream").refresh().then(rollback, rollback); }, toggleWiki(post) { @@ -692,11 +689,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return post.unhide(); }, - changePostOwner(post) { - this.get('selectedPosts').addObject(post); - this.send('changeOwner'); - }, - convertToPublicTopic() { this.get('content').convertTopic("public"); }, @@ -742,99 +734,84 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }, - canMergeTopic: function() { - if (!this.get('model.details.can_move_posts')) return false; - return this.get('selectedPostsCount') > 0; - }.property('selectedPostsCount'), - - canSplitTopic: function() { - if (!this.get('model.details.can_move_posts')) return false; - if (this.get('allPostsSelected')) return false; - return this.get('selectedPostsCount') > 0; - }.property('selectedPostsCount'), - - canChangeOwner: function() { - if (!Discourse.User.current() || !Discourse.User.current().admin) return false; - return this.get('selectedPostsUsername') !== undefined; - }.property('selectedPostsUsername'), - - @computed('selectedPosts', 'selectedPostsCount', 'selectedPostsUsername') - canMergePosts(selectedPosts, selectedPostsCount, selectedPostsUsername) { - if (selectedPostsCount < 2) return false; - if (!selectedPosts.every(p => p.get('can_delete'))) return false; - return selectedPostsUsername !== undefined; - }, - - categories: Ember.computed.alias('site.categoriesList'), - - 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() { - const selectedPosts = this.get('selectedPosts'); - - if (this.get('allPostsSelected')) return true; - if (this.get('selectedPostsCount') === 0) return false; - - let canDelete = true; - selectedPosts.forEach(function(p) { - if (!p.get('can_delete')) { - canDelete = false; - return false; - } - }); - return canDelete; - }.property('selectedPostsCount'), - hasError: Ember.computed.or('model.notFoundHtml', 'model.message'), noErrorYet: Ember.computed.not('hasError'), - multiSelectChanged: function() { - // Deselect all posts when multi select is turned off - if (!this.get('multiSelect')) { - this.send('deselectAll'); - } - }.observes('multiSelect'), + categories: Ember.computed.alias('site.categoriesList'), - deselectPost(post) { - this.get('selectedPosts').removeObject(post); + selectedPostsCount: Ember.computed.alias('selectedPostIds.length'), - const selectedReplies = this.get('selectedReplies'); - selectedReplies.removeObject(post); + @computed('selectedPostIds', 'model.postStream.posts', 'selectedPostIds.[]', 'model.postStream.posts.[]') + selectedPosts(selectedPostIds, loadedPosts) { + return selectedPostIds.map(id => loadedPosts.find(p => p.id === id)) + .filter(post => post !== undefined); + }, - const selectedReply = selectedReplies.findBy('post_number', post.get('reply_to_post_number')); - if (selectedReply) { selectedReplies.removeObject(selectedReply); } + @computed('selectedPostsCount', 'selectedPosts', 'selectedPosts.[]') + selectedPostsUsername(selectedPostsCount, selectedPosts) { + if (selectedPosts.length < 1 || selectedPostsCount > selectedPosts.length) { return undefined; } + const username = selectedPosts[0].username; + return selectedPosts.every(p => p.username === username) ? username : undefined; + }, - this.set('allPostsSelected', false); + @computed('selectedPostsCount', 'model.postStream.stream.length') + selectedAllPosts(selectedPostsCount, postsCount) { + return selectedPostsCount >= postsCount; + }, + + canSelectAll: Ember.computed.not('selectedAllPosts'), + canDeselectAll: Ember.computed.alias('selectedAllPosts'), + + @computed('selectedPostsCount', 'selectedAllPosts', 'selectedPosts', 'selectedPosts.[]') + canDeleteSelected(selectedPostsCount, selectedAllPosts, selectedPosts) { + return selectedPostsCount > 0 && (selectedAllPosts || selectedPosts.every(p => p.can_delete)); + }, + + @computed('canMergeTopic', 'selectedAllPosts') + canSplitTopic(canMergeTopic, selectedAllPosts) { + return canMergeTopic && !selectedAllPosts; + }, + + @computed('model.details.can_move_posts', 'selectedPostsCount') + canMergeTopic(canMovePosts, selectedPostsCount) { + return canMovePosts && selectedPostsCount > 0; + }, + + @computed('currentUser.admin', 'selectedPostsCount', 'selectedPostsUsername') + canChangeOwner(isAdmin, selectedPostsCount, selectedPostsUsername) { + return isAdmin && selectedPostsCount > 0 && selectedPostsUsername !== undefined; + }, + + @computed('selectedPostsCount', 'selectedPostsUsername', 'selectedPosts', 'selectedPosts.[]') + canMergePosts(selectedPostsCount, selectedPostsUsername, selectedPosts) { + return selectedPostsCount > 1 && + selectedPostsUsername !== undefined && + selectedPosts.every(p => p.can_delete); + }, + + @observes("multiSelect") + _multiSelectChanged() { + this.set('selectedPostIds', []); }, postSelected(post) { - if (this.get('allPostsSelected')) { return true; } - if (this.get('selectedPosts').includes(post)) { return true; } - if (this.get('selectedReplies').findBy('post_number', post.get('reply_to_post_number'))) { return true; } - - return false; + return this.get('selectedAllPost') || this.get('selectedPostIds').includes(post.id); }, - loadingHTML: function() { + @computed + loadingHTML() { return spinnerHTML; - }.property(), + }, recoverTopic() { this.get('content').recover(); }, deleteTopic() { - this.get('content').destroy(Discourse.User.current()); + this.get('content').destroy(this.currentUser); }, - // Receive notifications for this topic subscribe() { - // Unsubscribe before subscribing again this.unsubscribe(); const refresh = (args) => this.appEvents.trigger('post-stream:refresh', args); @@ -929,38 +906,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, 500), unsubscribe() { - const topicId = this.get('content.id'); - if (!topicId) return; - - // there is a condition where the view never calls unsubscribe, navigate to a topic from a topic + // never unsubscribe when navigating from topic to topic + if (!this.get("content.id")) return; this.messageBus.unsubscribe('/topic/*'); }, - // Topic related reply() { this.replyToPost(); }, - performTogglePost(post) { - const selectedPosts = this.get('selectedPosts'); - if (this.postSelected(post)) { - this.deselectPost(post); - return false; - } else { - selectedPosts.addObject(post); - // If the user manually selects all posts, all posts are selected - this.set('allPostsSelected', selectedPosts.length === this.get('model.posts_count')); - return true; - } - }, - readPosts(topicId, postNumbers) { const topic = this.get("model"); const postStream = topic.get("postStream"); if (topic.get('id') === topicId) { postStream.get('posts').forEach(post => { - if (!post.read && postNumbers.indexOf(post.post_number) !== -1) { + if (!post.read && postNumbers.includes(post.post_number)) { post.set('read', true); this.appEvents.trigger('post-stream:refresh', { id: post.get('id') }); } @@ -973,16 +934,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // automatically unpin topics when the user reaches the bottom const max = _.max(postNumbers); if (topic.get("pinned") && max >= topic.get("highest_post_number")) { - Em.run.next(() => topic.clearPin()); + Ember.run.next(() => topic.clearPin()); } } } }, - _showFooter: function() { + @observes("model.postStream.loaded", "model.postStream.loadedAllPosts") + _showFooter() { const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts"); this.set("application.showFooter", showFooter); - }.observes("model.postStream.{loaded,loadedAllPosts}") + } }); diff --git a/app/assets/javascripts/discourse/mixins/selected-posts-count.js.es6 b/app/assets/javascripts/discourse/mixins/selected-posts-count.js.es6 deleted file mode 100644 index f4c17587b30..00000000000 --- a/app/assets/javascripts/discourse/mixins/selected-posts-count.js.es6 +++ /dev/null @@ -1,34 +0,0 @@ -export default Em.Mixin.create({ - - selectedPostsCount: function() { - if (this.get('allPostsSelected')) { - return this.get('model.posts_count') || this.get('topic.posts_count') || this.get('posts_count'); - } - - var sum = this.get('selectedPosts.length') || 0; - if (this.get('selectedReplies')) { - this.get('selectedReplies').forEach(function (p) { - sum += p.get('reply_count') || 0; - }); - } - - return sum; - }.property('selectedPosts.length', 'allPostsSelected', 'selectedReplies.length'), - - // The username that owns every selected post, or undefined if no selection or if ownership is mixed. - selectedPostsUsername: function() { - // Don't proceed if replies are selected or usernames are mixed - // Changing ownership in those cases normally doesn't make sense - if (this.get('selectedReplies') && this.get('selectedReplies').length > 0) { return undefined; } - if (this.get('selectedPosts').length <= 0) { return undefined; } - - const selectedPosts = this.get('selectedPosts'), - username = selectedPosts[0].username; - - if (selectedPosts.every(function(post) { return post.username === username; })) { - return username; - } else { - return undefined; - } - }.property('selectedPosts.length', 'selectedReplies.length') -}); diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 1a9ea463b9a..53544c04c03 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -538,7 +538,7 @@ export default RestModel.extend({ triggerDeletedPost(postId){ const existing = this._identityMap[postId]; - if (existing) { + if (existing && !existing.deleted_at) { const url = "/posts/" + postId; const store = this.store; diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 928210d9ed4..1cf218c9cd8 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -329,22 +329,17 @@ Post.reopenClass({ }); }, - deleteMany(selectedPosts, selectedReplies) { + deleteMany(post_ids) { return ajax("/posts/destroy_many", { type: 'DELETE', - data: { - post_ids: selectedPosts.map(function(p) { return p.get('id'); }), - reply_post_ids: selectedReplies.map(function(p) { return p.get('id'); }) - } + data: { post_ids } }); }, - mergePosts(selectedPosts) { + mergePosts(post_ids) { return ajax("/posts/merge_posts", { type: 'PUT', - data: { post_ids: selectedPosts.map(p => p.get('id')) } - }).catch(() => { - self.flash(I18n.t('topic.merge_posts.error')); + data: { post_ids } }); }, diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index faaf68d48ca..28a0cf5fdc8 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -181,8 +181,9 @@ currentPostChanged=(action "currentPostChanged") currentPostScrolled=(action "currentPostScrolled") bottomVisibleChanged=(action "bottomVisibleChanged") - selectPost=(action "toggledSelectedPost") - selectReplies=(action "toggledSelectedPostReplies") + togglePostSelection=(action "togglePostSelection") + selectReplies=(action "selectReplies") + selectBelow=(action "selectBelow") fillGapBefore=(action "fillGapBefore") fillGapAfter=(action "fillGapAfter")}} {{/unless}} diff --git a/app/assets/javascripts/discourse/widgets/post-stream.js.es6 b/app/assets/javascripts/discourse/widgets/post-stream.js.es6 index 415c7915684..863f7170119 100644 --- a/app/assets/javascripts/discourse/widgets/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-stream.js.es6 @@ -87,7 +87,6 @@ export default createWidget('post-stream', { if (attrs.multiSelect) { transformed.selected = attrs.selectedQuery(post); - transformed.selectedPostsCount = attrs.selectedPostsCount; } } diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index ac4cc3cbf75..427b7c3baef 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -37,15 +37,31 @@ createWidget('select-post', { html(attrs) { const buttons = []; - if (attrs.replyCount > 0 && !attrs.selected) { - buttons.push(this.attach('button', { label: 'topic.multi_select.select_replies', action: 'selectReplies' })); + if (!attrs.selected && attrs.post_number > 1) { + if (attrs.replyCount > 0) { + buttons.push(this.attach('button', { + label: 'topic.multi_select.select_replies.label', + title: 'topic.multi_select.select_replies.title', + action: 'selectReplies', + className: 'select-replies' + })); + } + buttons.push(this.attach('button', { + label: 'topic.multi_select.select_below.label', + title: 'topic.multi_select.select_below.title', + action: 'selectBelow', + className: 'select-below' + })); } - const selectPostKey = attrs.selected ? 'topic.multi_select.selected' : 'topic.multi_select.select'; - buttons.push(this.attach('button', { className: 'select-post', - label: selectPostKey, - labelOptions: { count: attrs.selectedPostsCount }, - action: 'selectPost' })); + const key = `topic.multi_select.${attrs.selected ? 'selected' : 'select' }_post`; + buttons.push(this.attach('button', { + label: key + ".label", + title: key + ".title", + action: 'togglePostSelection', + className: 'select-post' + })); + return buttons; } }); diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index c3498899a14..a091e7e69d0 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -7,8 +7,7 @@ require_dependency 'new_post_result_serializer' class PostsController < ApplicationController - # Need to be logged in for all actions here - before_action :ensure_logged_in, except: [:show, :replies, :by_number, :short_link, :reply_history, :revisions, :latest_revision, :expand_embed, :markdown_id, :markdown_num, :cooked, :latest, :user_posts_feed] + before_action :ensure_logged_in, except: [:show, :replies, :by_number, :short_link, :reply_history, :replyIids, :revisions, :latest_revision, :expand_embed, :markdown_id, :markdown_num, :cooked, :latest, :user_posts_feed] skip_before_action :preload_json, :check_xhr, only: [:markdown_id, :markdown_num, :short_link, :latest, :user_posts_feed] @@ -224,6 +223,11 @@ class PostsController < ApplicationController render_serialized(post.reply_history(params[:max_replies].to_i, guardian), PostSerializer) end + def reply_ids + post = find_post_from_params + render json: post.reply_ids(guardian).to_json + end + def destroy post = find_post_from_params RateLimiter.new(current_user, "delete_post", 3, 1.minute).performed! unless current_user.staff? diff --git a/app/models/post.rb b/app/models/post.rb index 9f98d6b05b9..8e51d89de43 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -654,6 +654,32 @@ class Post < ActiveRecord::Base Post.secured(guardian).where(id: post_ids).includes(:user, :topic).order(:id).to_a end + def reply_ids(guardian = nil) + replies = Post.exec_sql(" + WITH RECURSIVE breadcrumb(id, post_number, level) AS ( + SELECT id, post_number, 0 + FROM posts + WHERE id = :post_id + UNION + SELECT p.id, p.post_number, b.level + 1 + FROM posts p, breadcrumb b + WHERE b.post_number = p.reply_to_post_number + AND p.topic_id = :topic_id + ), breadcrumb_with_replies AS ( + SELECT b.id, b.level, COUNT(*) + FROM breadcrumb b, post_replies pr + WHERE pr.reply_id = b.id + GROUP BY b.id, b.level + ) SELECT id, level FROM breadcrumb_with_replies WHERE count = 1 ORDER BY id + ", post_id: id, topic_id: topic_id).to_a + + replies.map! { |r| { id: r["id"].to_i, level: r["level"].to_i } } + + secured_ids = Post.secured(guardian).where(id: replies.map { |r| r[:id] }).pluck(:id).to_set + + replies.reject { |r| r[:id] == id || !secured_ids.include?(r[:id]) } + end + def revert_to(number) return if number >= version post_revision = PostRevision.find_by(post_id: id, number: (number + 1)) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 3429a5e8bb9..e9dd0b4b7f5 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1858,7 +1858,18 @@ en: multi_select: select: 'select' selected: 'selected ({{count}})' - select_replies: 'select +replies' + select_post: + label: 'select' + title: 'Add post to selection' + selected_post: + label: 'selected' + title: 'Click to remove post from selection' + select_replies: + label: 'select +replies' + title: 'Add post and all its replies to selection' + select_below: + label: 'select +below' + title: 'Add post and all after it to selection' delete: delete selected cancel: cancel selecting select_all: select all @@ -1950,11 +1961,14 @@ en: share: "share a link to this post" more: "More" delete_replies: - confirm: - one: "Do you also want to delete the direct reply to this post?" - other: "Do you also want to delete the {{count}} direct replies to this post?" - yes_value: "Yes, delete the replies too" - no_value: "No, just this post" + confirm: "Do you also want to delete the replies to this post?" + direct_replies: + one: "Yes, and 1 direct reply" + other: "Yes, and {{count}} direct replies" + all_replies: + one: "Yes, and 1 reply" + other: "Yes, and all {{count}} replies" + just_the_post: "No, just this post" admin: "post admin actions" wiki: "Make Wiki" unwiki: "Remove Wiki" @@ -2051,11 +2065,10 @@ en: delete: confirm: one: "Are you sure you want to delete that post?" - other: "Are you sure you want to delete all those posts?" + other: "Are you sure you want to delete those {{count}} posts?" merge: confirm: - one: "Are you sure you want to merge those posts?" other: "Are you sure you want to merge those {{count}} posts?" revisions: diff --git a/config/routes.rb b/config/routes.rb index 1e44790ce1e..1b2df375fd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -434,6 +434,7 @@ Discourse::Application.routes.draw do get "private-posts" => "posts#latest", id: "private_posts" get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/:id/reply-history" => "posts#reply_history" + get "posts/:id/reply-ids" => "posts#reply_ids" get "posts/:username/deleted" => "posts#deleted_posts", constraints: { username: USERNAME_ROUTE_FORMAT } get "posts/:username/flagged" => "posts#flagged_posts", constraints: { username: USERNAME_ROUTE_FORMAT } diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 99b5e898bce..2d5718ae59c 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -777,6 +777,32 @@ describe Post do end + context "reply_ids" do + + let!(:topic) { Fabricate(:topic) } + let!(:p1) { Fabricate(:post, topic: topic, post_number: 1) } + let!(:p2) { Fabricate(:post, topic: topic, post_number: 2, reply_to_post_number: 1) } + let!(:p3) { Fabricate(:post, topic: topic, post_number: 3) } + let!(:p4) { Fabricate(:post, topic: topic, post_number: 4, reply_to_post_number: 2) } + let!(:p5) { Fabricate(:post, topic: topic, post_number: 5, reply_to_post_number: 4) } + + before { + PostReply.create!(post: p1, reply: p2) + PostReply.create!(post: p2, reply: p4) + PostReply.create!(post: p3, reply: p5) + PostReply.create!(post: p4, reply: p5) + } + + it "returns the reply ids and their level" do + expect(p1.reply_ids).to eq([{ id: p2.id, level: 1 }, { id: p4.id, level: 2 }]) + expect(p2.reply_ids).to eq([{ id: p4.id, level: 1 }]) + expect(p3.reply_ids).to be_empty # no replies + expect(p4.reply_ids).to be_empty # not a direct reply + expect(p5.reply_ids).to be_empty # last post + end + + end + describe 'urls' do it 'no-ops for empty list' do expect(Post.urls([])).to eq({}) diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index 1ae8080bc4e..574c371eefe 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -1,126 +1,318 @@ -import { mapRoutes } from 'discourse/mapping-router'; +import AppEvents from "discourse/lib/app-events"; +import Topic from "discourse/models/topic"; -moduleFor('controller:topic', 'controller:topic', { - needs: ['controller:modal', 'controller:composer', 'controller:application'], +moduleFor("controller:topic", "controller:topic", { + needs: ["controller:composer", "controller:application"], beforeEach() { - this.registry.register('router:main', mapRoutes()); - }, + this.registry.register("app-events:main", AppEvents.create(), { instantiate: false }); + this.registry.injection("controller", "appEvents", "app-events:main"); + } }); -import Topic from 'discourse/models/topic'; -import AppEvents from 'discourse/lib/app-events'; +QUnit.test("editTopic", function(assert) { + const model = Topic.create(); + const controller = this.subject({ model }); -var buildTopic = function() { - return Topic.create({ - title: "Qunit Test Topic", - participants: [ - {id: 1234, - post_count: 4, - username: "eviltrout"} - ] - }); -}; + assert.not(controller.get("editingTopic"), "we are not editing by default"); + controller.set("model.details.can_edit", false); + controller.send("editTopic"); -QUnit.test("editingMode", function(assert) { - var topic = buildTopic(), - topicController = this.subject({model: topic}); + assert.not(controller.get("editingTopic"), "calling editTopic doesn't enable editing unless the user can edit"); - assert.ok(!topicController.get('editingTopic'), "we are not editing by default"); + controller.set("model.details.can_edit", true); + controller.send("editTopic"); - topicController.set('model.details.can_edit', false); - topicController.send('editTopic'); - assert.ok(!topicController.get('editingTopic'), "calling editTopic doesn't enable editing unless the user can edit"); + assert.ok(controller.get("editingTopic"), "calling editTopic enables editing if the user can edit"); + assert.equal(controller.get("buffered.title"), model.get("title")); + assert.equal(controller.get("buffered.category_id"), model.get("category_id")); - topicController.set('model.details.can_edit', true); - topicController.send('editTopic'); - assert.ok(topicController.get('editingTopic'), "calling editTopic enables editing if the user can edit"); - assert.equal(topicController.get('buffered.title'), topic.get('title')); - assert.equal(topicController.get('buffered.category_id'), topic.get('category_id')); + controller.send("cancelEditingTopic"); - topicController.send('cancelEditingTopic'); - assert.ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value"); + assert.not(controller.get("editingTopic"), "cancelling edit mode reverts the property value"); }); -QUnit.test("toggledSelectedPost", function(assert) { - var tc = this.subject({ model: buildTopic() }), - post = Discourse.Post.create({id: 123, post_number: 2}), - postStream = tc.get('model.postStream'); +QUnit.test("toggleMultiSelect", function(assert) { + const model = Topic.create(); + const controller = this.subject({ model }); - postStream.appendPost(post); - postStream.appendPost(Discourse.Post.create({id: 124, post_number: 3})); + assert.not(controller.get("multiSelect"), "multi selection mode is disabled by default"); - assert.blank(tc.get('selectedPosts'), "there are no selected posts by default"); - assert.equal(tc.get('selectedPostsCount'), 0, "there is a selected post count of 0"); - assert.ok(!tc.postSelected(post), "the post is not selected by default"); + controller.get("selectedPostIds").pushObject(1); + assert.equal(controller.get("selectedPostIds.length"), 1); - tc.send('toggledSelectedPost', post); - assert.present(tc.get('selectedPosts'), "there is a selectedPosts collection"); - assert.equal(tc.get('selectedPostsCount'), 1, "there is a selected post now"); - assert.ok(tc.postSelected(post), "the post is now selected"); + controller.send("toggleMultiSelect"); - tc.send('toggledSelectedPost', post); - assert.ok(!tc.postSelected(post), "the post is no longer selected"); + assert.ok(controller.get("multiSelect"), "calling 'toggleMultiSelect' once enables multi selection mode"); + assert.equal(controller.get("selectedPostIds.length"), 0, "toggling 'multiSelect' clears 'selectedPostIds'"); + controller.get("selectedPostIds").pushObject(2); + assert.equal(controller.get("selectedPostIds.length"), 1); + + controller.send("toggleMultiSelect"); + + assert.not(controller.get("multiSelect"), "calling 'toggleMultiSelect' twice disables multi selection mode"); + assert.equal(controller.get("selectedPostIds.length"), 0, "toggling 'multiSelect' clears 'selectedPostIds'"); }); -QUnit.test("selectAll", function(assert) { - var tc = this.subject({model: buildTopic(), appEvents: AppEvents.create()}), - post = Discourse.Post.create({id: 123, post_number: 2}), - postStream = tc.get('model.postStream'); +QUnit.test("selectedPosts", function(assert) { + const postStream = { posts: [{ id: 1 }, { id: 2 }, { id: 3 }] }; + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); - postStream.appendPost(post); - - assert.ok(!tc.postSelected(post), "the post is not selected by default"); - tc.send('selectAll'); - assert.ok(tc.postSelected(post), "the post is now selected"); - assert.ok(tc.get('allPostsSelected'), "all posts are selected"); - tc.send('deselectAll'); - assert.ok(!tc.postSelected(post), "the post is deselected again"); - assert.ok(!tc.get('allPostsSelected'), "all posts are not selected"); + controller.set("selectedPostIds", [1, 2, 42]); + assert.equal(controller.get("selectedPosts.length"), 2, "selectedPosts only contains already loaded posts"); + assert.not(controller.get("selectedPosts").some(p => p === undefined), "selectedPosts only contains valid post objects"); }); -QUnit.test("Automating setting of allPostsSelected", function(assert) { - var topic = buildTopic(), - tc = this.subject({model: topic}), - post = Discourse.Post.create({id: 123, post_number: 2}), - postStream = tc.get('model.postStream'); +QUnit.test("selectedAllPosts", function(assert) { + const postStream = { stream: [1, 2, 3] }; + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); - topic.set('posts_count', 1); - postStream.appendPost(post); - assert.ok(!tc.get('allPostsSelected'), "all posts are not selected by default"); + controller.set("selectedPostIds", [1, 2]); - tc.send('toggledSelectedPost', post); - assert.ok(tc.get('allPostsSelected'), "all posts are selected if we select the only post"); + assert.not(controller.get("selectedAllPosts"), "not all posts are selected"); - tc.send('toggledSelectedPost', post); - assert.ok(!tc.get('allPostsSelected'), "the posts are no longer automatically selected"); + controller.get("selectedPostIds").pushObject(3); + + assert.ok(controller.get("selectedAllPosts"), "all posts are selected"); + + controller.get("selectedPostIds").pushObject(42); + + assert.ok(controller.get("selectedAllPosts"), "all posts (including filtered posts) are selected"); }); -QUnit.test("Select Replies when present", function(assert) { - var topic = buildTopic(), - tc = this.subject({ model: topic, appEvents: AppEvents.create() }), - 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}); +QUnit.test("selectedPostsUsername", function(assert) { + const postStream = { + posts: [ + { id: 1, username: "gary" }, + { id: 2, username: "gary" }, + { id: 3, username: "lili" }, + ], + stream: [1, 2, 3] + }; - assert.ok(!tc.postSelected(p3), "replies are not selected by default"); - tc.send('toggledSelectedPostReplies', p1); - assert.ok(tc.postSelected(p1), "it selects the post"); - assert.ok(!tc.postSelected(p2), "it doesn't select a post that's not a reply"); - assert.ok(tc.postSelected(p3), "it selects a post that is a reply"); - assert.equal(tc.get('selectedPostsCount'), 2, "it has a selected posts count of two"); + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); - // If we deselected the post whose replies are selected... - tc.send('toggledSelectedPost', p1); - assert.ok(!tc.postSelected(p1), "it deselects the post"); - assert.ok(!tc.postSelected(p3), "it deselects the replies too"); + assert.equal(controller.get("selectedPostsUsername"), undefined, "no username when no selected posts"); - // If we deselect a reply, it should deselect the parent's replies selected attribute. Weird but what else would make sense? - tc.send('toggledSelectedPostReplies', p1); - tc.send('toggledSelectedPost', p3); - assert.ok(tc.postSelected(p1), "the post stays selected"); - assert.ok(!tc.postSelected(p3), "it deselects the replies too"); + selectedPostIds.pushObject(1); + assert.equal(controller.get("selectedPostsUsername"), "gary", "username of the selected posts"); + + selectedPostIds.pushObject(2); + + assert.equal(controller.get("selectedPostsUsername"), "gary", "username of all the selected posts when same user"); + + selectedPostIds.pushObject(3); + + assert.equal(controller.get("selectedPostsUsername"), undefined, "no username when more than 1 user"); + + selectedPostIds.replace(2, 1, [42]); + + assert.equal(controller.get("selectedPostsUsername"), undefined, "no username when not already loaded posts are selected"); }); + +QUnit.test("showSelectedPostsAtBottom", function(assert) { + const site = Ember.Object.create({ mobileView: false }); + const model = Topic.create({ posts_count: 3 }); + const controller = this.subject({ model, site }); + + assert.not(controller.get("showSelectedPostsAtBottom"), "false on desktop") + + site.set("mobileView", true); + + assert.not(controller.get("showSelectedPostsAtBottom"), "requires at least 3 posts on mobile"); + + model.set("posts_count", 4); + + assert.ok(controller.get("showSelectedPostsAtBottom"), "true when mobile and more than 3 posts"); +}); + +QUnit.test("canDeleteSelected", function(assert) { + const postStream = { + posts: [ + { id: 1, can_delete: false }, + { id: 2, can_delete: true }, + { id: 3, can_delete: true } + ], + stream: [1, 2, 3] + }; + + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.not(controller.get("canDeleteSelected"), "false when no posts are selected"); + + selectedPostIds.pushObject(1); + + assert.not(controller.get("canDeleteSelected"), "false when can't delete one of the selected posts"); + + selectedPostIds.replace(0, 1, [2, 3]); + + assert.ok(controller.get("canDeleteSelected"), "true when all selected posts can be deleted"); + + selectedPostIds.pushObject(1); + + assert.ok(controller.get("canDeleteSelected"), "true when all posts are selected"); +}); + +QUnit.test("Can split/merge topic", function(assert) { + const postStream = { + posts: [{ id: 1 }, { id: 2 }], + stream: [1, 2] + }; + + const model = Topic.create({ postStream, details: { can_move_posts: false } }); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.not(controller.get("canSplitTopic"), "can't split topic when no posts are selected"); + assert.not(controller.get("canMergeTopic"), "can't merge topic when no posts are selected"); + + selectedPostIds.pushObject(1); + + assert.not(controller.get("canSplitTopic"), "can't split topic when can't move posts"); + assert.not(controller.get("canMergeTopic"), "can't merge topic when can't move posts"); + + model.set("details.can_move_posts", true); + + assert.ok(controller.get("canSplitTopic"), "can split topic"); + assert.ok(controller.get("canMergeTopic"), "can merge topic"); + + selectedPostIds.pushObject(2); + + assert.not(controller.get("canSplitTopic"), "can't split topic when all posts are selected"); + assert.ok(controller.get("canMergeTopic"), "can merge topic when all posts are selected"); +}); + +QUnit.test("canChangeOwner", function(assert) { + const currentUser = Discourse.User.create({ admin: false }); + this.registry.register("current-user:main", currentUser, { instantiate: false }); + this.registry.injection("controller", "currentUser", "current-user:main"); + + const postStream = { + posts: [ + { id: 1, username: "gary" }, + { id: 2, username: "lili" }, + ], + stream: [1, 2] + }; + + const model = Topic.create({ postStream, currentUser: { admin: false }}); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.not(controller.get("canChangeOwner"), "false when no posts are selected"); + + selectedPostIds.pushObject(1); + + assert.not(controller.get("canChangeOwner"), "false when not admin"); + + currentUser.set("admin", true); + + assert.ok(controller.get("canChangeOwner"), "true when admin and one post is selected"); + + selectedPostIds.pushObject(2); + + assert.not(controller.get("canChangeOwner"), "false when admin but more than 1 user"); +}); + +QUnit.test("canMergePosts", function(assert) { + const postStream = { + posts: [ + { id: 1, username: "gary", can_delete: true }, + { id: 2, username: "lili", can_delete: true }, + { id: 3, username: "gary", can_delete: false }, + { id: 4, username: "gary", can_delete: true }, + ], + stream: [1, 2, 3] + }; + + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.not(controller.get("canMergePosts"), "false when no posts are selected"); + + selectedPostIds.pushObject(1); + + assert.not(controller.get("canMergePosts"), "false when only one post is selected"); + + selectedPostIds.pushObject(2); + + assert.not(controller.get("canMergePosts"), "false when selected posts are from different users"); + + selectedPostIds.replace(1, 1, [3]); + + assert.not(controller.get("canMergePosts"), "false when selected posts can't be deleted"); + + selectedPostIds.replace(1, 1, [4]); + + assert.ok(controller.get("canMergePosts"), "true when all selected posts are deletable and by the same user"); +}); + +QUnit.test("Select/deselect all", function(assert) { + const postStream = { stream: [1, 2, 3] }; + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); + + assert.equal(controller.get("selectedPostsCount"), 0, "no posts selected by default"); + + controller.send("selectAll"); + + assert.equal(controller.get("selectedPostsCount"), postStream.stream.length, "calling 'selectAll' selects all posts"); + + controller.send("deselectAll"); + + assert.equal(controller.get("selectedPostsCount"), 0, "calling 'deselectAll' deselects all posts"); +}); + +QUnit.test("togglePostSelection", function(assert) { + const controller = this.subject(); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.equal(selectedPostIds[0], undefined, "no posts selected by default"); + + controller.send("togglePostSelection", { id: 1 }); + + assert.equal(selectedPostIds[0], 1, "adds the selected post id if not already selected"); + + controller.send("togglePostSelection", { id: 1 }); + + assert.equal(selectedPostIds[0], undefined, "removes the selected post id if already selected"); +}); + +// QUnit.test("selectReplies", function(assert) { +// const controller = this.subject(); +// const selectedPostIds = controller.get("selectedPostIds"); +// +// assert.equal(selectedPostIds[0], undefined, "no posts selected by default"); +// +// controller.send("selectReplies", { id: 42 }); +// +// assert.equal(selectedPostIds[0], 42, "selected post #42"); +// assert.equal(selectedPostIds[1], 45, "selected post #45"); +// assert.equal(selectedPostIds[2], 100, "selected post #100"); +// }); + +QUnit.test("selectBelow", function(assert) { + const postStream = { stream: [1, 2, 3, 4, 5] }; + const model = Topic.create({ postStream }); + const controller = this.subject({ model }); + const selectedPostIds = controller.get("selectedPostIds"); + + assert.equal(selectedPostIds[0], undefined, "no posts selected by default"); + + controller.send("selectBelow", { id: 3 }); + + assert.equal(selectedPostIds[0], 3, "selected post #3"); + assert.equal(selectedPostIds[1], 4, "also selected 1st post below post #3"); + assert.equal(selectedPostIds[2], 5, "also selected 2nd post below post #3"); +}); + diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 56c119ad85c..1c18061fa13 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -306,6 +306,10 @@ export default function() { return response(200, [ { id: 2222, post_number: 2222 } ]); }); + this.get("/posts/:post_id/reply-ids.json", () => { + return response(200, { direct_reply_ids: [45], all_reply_ids: [45, 100] }); + }); + this.post('/user_badges', () => response(200, fixturesByUrl['/user_badges'])); this.delete('/user_badges/:badge_id', success); diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index 6cf586009f3..19bf018c471 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -9,7 +9,6 @@ import { clearHTMLCache } from 'discourse/helpers/custom-html'; import { flushMap } from 'discourse/models/store'; import { clearRewrites } from 'discourse/lib/url'; - export function currentUser() { return Discourse.User.create(sessionFixtures['/session/current.json'].current_user); } diff --git a/test/javascripts/mixins/selected-posts-count-test.js.es6 b/test/javascripts/mixins/selected-posts-count-test.js.es6 deleted file mode 100644 index 182ad85de2d..00000000000 --- a/test/javascripts/mixins/selected-posts-count-test.js.es6 +++ /dev/null @@ -1,36 +0,0 @@ -QUnit.module("mixin:selected-posts-count"); - -import SelectedPostsCount from 'discourse/mixins/selected-posts-count'; -import Topic from 'discourse/models/topic'; - -var buildTestObj = function(params) { - return Ember.Object.extend(SelectedPostsCount).create(params || {}); -}; - -QUnit.test("without selectedPosts", assert => { - var testObj = buildTestObj(); - - assert.equal(testObj.get('selectedPostsCount'), 0, "No posts are selected without a selectedPosts property"); - - testObj.set('selectedPosts', []); - assert.equal(testObj.get('selectedPostsCount'), 0, "No posts are selected when selectedPosts is an empty array"); -}); - -QUnit.test("with some selectedPosts", assert => { - var testObj = buildTestObj({ selectedPosts: [Discourse.Post.create({id: 123})] }); - assert.equal(testObj.get('selectedPostsCount'), 1, "It returns the amount of posts"); -}); - -QUnit.test("when all posts are selected and there is a posts_count", assert => { - var testObj = buildTestObj({ allPostsSelected: true, posts_count: 1024 }); - assert.equal(testObj.get('selectedPostsCount'), 1024, "It returns the posts_count"); -}); - -QUnit.test("when all posts are selected and there is topic with a posts_count", assert => { - var testObj = buildTestObj({ - allPostsSelected: true, - topic: Topic.create({ posts_count: 3456 }) - }); - - assert.equal(testObj.get('selectedPostsCount'), 3456, "It returns the topic's posts_count"); -}); \ No newline at end of file