FEATURE: Added 'select +below' and 'select +all replies' options to selecting posts

This commit is contained in:
Régis Hanol 2017-12-13 22:12:06 +01:00
parent b15059418b
commit 1b4483c942
19 changed files with 739 additions and 593 deletions

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
});

View File

@ -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}")
}
});

View File

@ -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')
});

View File

@ -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;

View File

@ -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 }
});
},

View File

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

View File

@ -87,7 +87,6 @@ export default createWidget('post-stream', {
if (attrs.multiSelect) {
transformed.selected = attrs.selectedQuery(post);
transformed.selectedPostsCount = attrs.selectedPostsCount;
}
}

View File

@ -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;
}
});

View File

@ -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?

View File

@ -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))

View File

@ -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:

View File

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

View File

@ -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({})

View File

@ -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");
});

View File

@ -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);

View File

@ -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);
}

View File

@ -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");
});