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 ModalFunctionality from 'discourse/mixins/modal-functionality'; import DiscourseURL from "discourse/lib/url";
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, saving: false,
new_user: null, new_user: null,
buttonDisabled: function() { selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"),
if (this.get('saving')) return true; selectedPostsUsername: Ember.computed.alias("topicController.selectedPostsUsername"),
return Ember.isEmpty(this.get('new_user'));
}.property('saving', 'new_user'),
buttonTitle: function() { @computed("saving", "new_user")
if (this.get('saving')) return I18n.t('saving'); buttonDisabled(saving, newUser) {
return I18n.t('topic.change_owner.action'); return saving || Ember.isEmpty(newUser);
}.property('saving'), },
onShow: function() { @computed("saving")
buttonTitle(saving) {
return saving ? I18n.t("saving") : I18n.t("topic.change_owner.action");
},
onShow() {
this.setProperties({ this.setProperties({
saving: false, saving: false,
new_user: '' new_user: ""
}); });
}, },
actions: { actions: {
changeOwnershipOfPosts: function() { changeOwnershipOfPosts() {
this.set('saving', true); this.set("saving", true);
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), const options = {
self = this, post_ids: this.get("topicController.selectedPostIds"),
saveOpts = { username: this.get("new_user"),
post_ids: postIds, };
username: this.get('new_user')
};
Discourse.Topic.changeOwners(this.get('topicController.model.id'), saveOpts).then(function() { Discourse.Topic.changeOwners(this.get("topicController.model.id"), options).then(() => {
// success this.send("closeModal");
self.send('closeModal'); this.get("topicController").send("deselectAll");
self.get('topicController').send('deselectAll'); if (this.get("topicController.multiSelect")) {
if (self.get('topicController.multiSelect')) { this.get("topicController").send("toggleMultiSelect");
self.get('topicController').send('toggleMultiSelect');
} }
Em.run.next(() => { DiscourseURL.routeTo(self.get("topicController.model.url")); }); Ember.run.next(() => DiscourseURL.routeTo(this.get("topicController.model.url")));
}, function() { }, () => {
// failure this.flash(I18n.t("topic.change_owner.error"), "alert-error");
self.flash(I18n.t('topic.change_owner.error'), 'alert-error'); this.set("saving", false);
self.set('saving', false);
}); });
return 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 ModalFunctionality from 'discourse/mixins/modal-functionality'; import { movePosts, mergeTopic } from "discourse/models/topic";
import { movePosts, mergeTopic } from 'discourse/models/topic'; import DiscourseURL from "discourse/lib/url";
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(ModalFunctionality, {
export default Ember.Controller.extend(SelectedPostsCount, ModalFunctionality, { topicController: Ember.inject.controller("topic"),
topicController: Ember.inject.controller('topic'),
saving: false, saving: false,
selectedTopicId: null, selectedTopicId: null,
selectedPosts: Em.computed.alias('topicController.selectedPosts'), selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
allPostsSelected: Em.computed.alias('topicController.allPostsSelected'),
buttonDisabled: function() { @computed("saving", "selectedTopicId")
if (this.get('saving')) return true; buttonDisabled(saving, selectedTopicId) {
return Ember.isEmpty(this.get('selectedTopicId')); return saving || Ember.isEmpty(selectedTopicId);
}.property('selectedTopicId', 'saving'), },
buttonTitle: function() { @computed("saving")
if (this.get('saving')) return I18n.t('saving'); buttonTitle(saving) {
return I18n.t('topic.merge_topic.title'); return saving ? I18n.t("saving") : I18n.t("topic.merge_topic.title");
}.property('saving'), },
onShow() { onShow() {
this.set('modal.modalClass', 'split-modal'); this.set("modal.modalClass", "split-modal");
}, },
actions: { actions: {
movePostsToExistingTopic() { movePostsToExistingTopic() {
const topicId = this.get('model.id'); const topicId = this.get("model.id");
this.set('saving', true); this.set("saving", true);
let promise = null; let promise = this.get("topicController.selectedAllPosts") ?
if (this.get('allPostsSelected')) { mergeTopic(topicId, this.get("selectedTopicId")) :
promise = mergeTopic(topicId, this.get('selectedTopicId')); movePosts(topicId, {
} else { destination_topic_id: this.get("selectedTopicId"),
const postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }); post_ids: this.get("topicController.selectedPostIds")
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
}); });
}
const self = this; promise.then(result => {
promise.then(function(result) { this.send("closeModal");
// Posts moved this.get("topicController").send("toggleMultiSelect");
self.send('closeModal'); Ember.run.next(() => DiscourseURL.routeTo(result.url));
self.get('topicController').send('toggleMultiSelect'); }).catch(() => {
Em.run.next(function() { DiscourseURL.routeTo(result.url); }); this.flash(I18n.t("topic.merge_topic.error"));
}).catch(function() { }).finally(() => {
self.flash(I18n.t('topic.merge_topic.error')); this.set("saving", false);
}).finally(function() {
self.set('saving', false);
}); });
return 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 ModalFunctionality from 'discourse/mixins/modal-functionality'; import { extractError } from "discourse/lib/ajax-error";
import { extractError } from 'discourse/lib/ajax-error'; import { movePosts } from "discourse/models/topic";
import { movePosts } from 'discourse/models/topic'; import DiscourseURL from "discourse/lib/url";
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(ModalFunctionality, {
export default Ember.Controller.extend(SelectedPostsCount, ModalFunctionality, {
topicName: null, topicName: null,
saving: false, saving: false,
categoryId: null, categoryId: null,
topicController: Ember.inject.controller('topic'), topicController: Ember.inject.controller("topic"),
selectedPosts: Em.computed.alias('topicController.selectedPosts'), selectedPostsCount: Ember.computed.alias("topicController.selectedPostsCount"),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
allPostsSelected: Em.computed.alias('topicController.allPostsSelected'),
buttonDisabled: function() { @computed("saving", "topicName")
if (this.get('saving')) return true; buttonDisabled(saving, topicName) {
return Ember.isEmpty(this.get('topicName')); return saving || Ember.isEmpty(topicName);
}.property('saving', 'topicName'), },
buttonTitle: function() { @computed("saving")
if (this.get('saving')) return I18n.t('saving'); buttonTitle(saving) {
return I18n.t('topic.split_topic.action'); return saving ? I18n.t("saving") : I18n.t("topic.split_topic.action");
}.property('saving'), },
onShow() { onShow() {
this.setProperties({ this.setProperties({
'modal.modalClass': 'split-modal', "modal.modalClass": "split-modal",
saving: false, saving: false,
categoryId: null, categoryId: null,
topicName: '' topicName: ""
}); });
}, },
actions: { actions: {
movePostsToNewTopic() { movePostsToNewTopic() {
this.set('saving', true); this.set("saving", true);
const postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), const options = {
replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }), title: this.get("topicName"),
self = this, post_ids: this.get("topicController.selectedPostIds"),
categoryId = this.get('categoryId'), category_id: this.get("categoryId")
saveOpts = { };
title: this.get('topicName'),
post_ids: postIds,
reply_post_ids: replyPostIds
};
if (!Ember.isNone(categoryId)) { saveOpts.category_id = categoryId; } movePosts(this.get("model.id"), options).then(result => {
this.send("closeModal");
movePosts(this.get('model.id'), saveOpts).then(function(result) { this.get("topicController").send("toggleMultiSelect");
// Posts moved Ember.run.next(() => DiscourseURL.routeTo(result.url));
self.send('closeModal'); }).catch(xhr => {
self.get('topicController').send('toggleMultiSelect'); this.flash(extractError(xhr, I18n.t("topic.split_topic.error")));
Ember.run.next(function() { DiscourseURL.routeTo(result.url); }); }).finally(() => {
}).catch(function(xhr) { this.set("saving", false);
self.flash(extractError(xhr, I18n.t('topic.split_topic.error')));
}).finally(function() {
self.set('saving', false);
}); });
return false; return false;
} }
} }
}); });

View File

@ -1,27 +1,25 @@
import BufferedContent from 'discourse/mixins/buffered-content'; 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 Composer from 'discourse/models/composer';
import DiscourseURL from 'discourse/lib/url'; import DiscourseURL from 'discourse/lib/url';
import Post from 'discourse/models/post'; 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 debounce from 'discourse/lib/debounce';
import isElementInViewport from "discourse/lib/is-element-in-viewport"; import isElementInViewport from "discourse/lib/is-element-in-viewport";
import QuoteState from 'discourse/lib/quote-state'; import { ajax } from 'discourse/lib/ajax';
import { userPath } from 'discourse/lib/url'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
import { extractLinkMeta } from 'discourse/lib/render-topic-featured-link'; 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(), composer: Ember.inject.controller(),
application: Ember.inject.controller(), application: Ember.inject.controller(),
multiSelect: false, multiSelect: false,
allPostsSelected: false, selectedPostIds: null,
editingTopic: false, editingTopic: false,
selectedPosts: null,
selectedReplies: null,
queryParams: ['filter', 'username_filters'], queryParams: ['filter', 'username_filters'],
loadedAllPosts: Ember.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'), loadedAllPosts: Ember.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
enteredAt: null, enteredAt: null,
@ -36,29 +34,26 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
canRemoveTopicFeaturedLink: Ember.computed.and('canEditTopicFeaturedLink', 'buffered.featured_link'), canRemoveTopicFeaturedLink: Ember.computed.and('canEditTopicFeaturedLink', 'buffered.featured_link'),
updateQueryParams() { updateQueryParams() {
const postStream = this.get('model.postStream'); this.setProperties(this.get('model.postStream.streamFilters'));
this.setProperties(postStream.get('streamFilters'));
}, },
_titleChanged: function() { @observes('model.title', 'category')
_titleChanged() {
const title = this.get('model.title'); const title = this.get('model.title');
if (!Ember.isEmpty(title)) { if (!Ember.isEmpty(title)) {
// force update lazily loaded titles
// Note normally you don't have to trigger this, but topic titles can be updated
// and are sometimes lazily loaded.
this.send('refreshTitle'); this.send('refreshTitle');
} }
}.observes('model.title', 'category'), },
@computed('site.mobileView', 'model.posts_count') @computed('site.mobileView', 'model.posts_count')
showSelectedPostsAtBottom(mobileView, postsCount) { showSelectedPostsAtBottom(mobileView, postsCount) {
return mobileView && (postsCount > 3); return mobileView && postsCount > 3;
}, },
@computed('model.postStream.posts') @computed('model.postStream.posts', 'model.postStream.postsWithPlaceholders')
postsToRender() { postsToRender(posts, postsWithPlaceholders) {
return this.capabilities.isAndroid ? this.get('model.postStream.posts') return this.capabilities.isAndroid ? posts : postsWithPlaceholders;
: this.get('model.postStream.postsWithPlaceholders');
}, },
@computed('model.postStream.loadingFilter') @computed('model.postStream.loadingFilter')
@ -66,17 +61,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return this.capabilities.isAndroid && loading; return this.capabilities.isAndroid && loading;
}, },
@computed('model') @computed('model')
pmPath(model) { pmPath(topic) {
return this.currentUser && this.currentUser.pmPath(model); return this.currentUser && this.currentUser.pmPath(topic);
}, },
init() { init() {
this._super(); this._super();
this.set('selectedPosts', []); this.setProperties({
this.set('selectedReplies', []); selectedPostIds: [],
this.set('quoteState', new QuoteState()); quoteState: new QuoteState(),
});
}, },
showCategoryChooser: Ember.computed.not("model.isPrivateMessage"), showCategoryChooser: Ember.computed.not("model.isPrivateMessage"),
@ -89,22 +84,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
DiscourseURL.routeTo(url); DiscourseURL.routeTo(url);
}, },
selectedQuery: function() { @computed
selectedQuery() {
return post => this.postSelected(post); return post => this.postSelected(post);
}.property(), },
@computed('model.isPrivateMessage', 'model.category.id') @computed('model.isPrivateMessage', 'model.category.id')
canEditTopicFeaturedLink(isPrivateMessage, categoryId) { canEditTopicFeaturedLink(isPrivateMessage, categoryId) {
if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; } if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; }
const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); 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') @computed('model')
featuredLinkDomain(topic) { featuredLinkDomain(topic) {
const meta = extractLinkMeta(topic); return extractLinkMeta(topic).domain;
return meta.domain;
}, },
@computed('model.isPrivateMessage') @computed('model.isPrivateMessage')
@ -258,16 +253,15 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
// Archive a PM (as opposed to archiving a topic) // Archive a PM (as opposed to archiving a topic)
toggleArchiveMessage() { toggleArchiveMessage() {
const topic = this.get('model'); const topic = this.get('model');
if (topic.get('archiving')) { return; } if (topic.get('archiving')) { return; }
const backToInbox = () => this.goToInbox(topic.get("inboxGroupName"));
if (topic.get('message_archived')) { if (topic.get('message_archived')) {
topic.moveToInbox().then(()=>{ topic.moveToInbox().then(backToInbox);
this.gotoInbox(topic.get("inboxGroupName"));
});
} else { } else {
topic.archiveMessage().then(()=>{ topic.archiveMessage().then(backToInbox);
this.gotoInbox(topic.get("inboxGroupName"));
});
} }
}, },
@ -275,10 +269,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
replyToPost(post) { replyToPost(post) {
const composerController = this.get('composer'); const composerController = this.get('composer');
const topic = post ? post.get('topic') : this.get('model'); const topic = post ? post.get('topic') : this.get('model');
const quoteState = this.get('quoteState'); const quoteState = this.get('quoteState');
const postStream = this.get('model.postStream'); const postStream = this.get('model.postStream');
if (!postStream) return; if (!postStream) return;
const quotedPost = postStream.findLoadedPost(quoteState.postId); const quotedPost = postStream.findLoadedPost(quoteState.postId);
const quotedText = Quote.build(quotedPost, quoteState.buffer); const quotedText = Quote.build(quotedPost, quoteState.buffer);
@ -290,7 +285,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
composerController.set('content.composeState', Composer.OPEN); composerController.set('content.composeState', Composer.OPEN);
this.appEvents.trigger('composer:insert-block', quotedText.trim()); this.appEvents.trigger('composer:insert-block', quotedText.trim());
} else { } else {
const opts = { const opts = {
action: Composer.REPLY, action: Composer.REPLY,
draftKey: topic.get('draft_key'), draftKey: topic.get('draft_key'),
@ -311,78 +305,94 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}, },
recoverPost(post) { recoverPost(post) {
// Recovering the first post recovers the topic instead post.get("post_number") === 1 ? this.recoverTopic() : this.recover();
if (post.get('post_number') === 1) {
this.recoverTopic();
return;
}
post.recover();
}, },
deletePost(post) { deletePost(post) {
// Deleting the first post deletes the topic
if (post.get('post_number') === 1) { if (post.get('post_number') === 1) {
return this.deleteTopic(); return this.deleteTopic();
} else if (!post.can_delete) { } else if (!post.can_delete) {
// check if current user can delete post
return false; return false;
} }
const user = Discourse.User.current(), const user = this.currentUser;
replyCount = post.get('reply_count'), const refresh = () => this.appEvents.trigger('post-stream:refresh');
self = this; 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') && hasReplies) {
if (user.get('staff') && replyCount > 0) { ajax(`/posts/${post.id}/reply-ids.json`).then(replies => {
bootbox.dialog(I18n.t("post.controls.delete_replies.confirm", {count: replyCount}), [ const buttons = [];
{label: I18n.t("cancel"),
'class': 'btn-danger right'}, buttons.push({
{label: I18n.t("post.controls.delete_replies.no_value"), label: I18n.t('cancel'),
'class': 'btn-danger right'
});
buttons.push({
label: I18n.t('post.controls.delete_replies.just_the_post'),
callback() { callback() {
post.destroy(user); post.destroy(user)
} .then(refresh)
}, .catch(error => {
{label: I18n.t("post.controls.delete_replies.yes_value"), popupAjaxError(error);
'class': 'btn-primary', post.undoDeleteState();
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);
}
});
} }
});
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 { const directReplyIds = replies.filter(r => r.level === 1).map(r => r.id);
return post.destroy(user).then(() => {
this.appEvents.trigger('post-stream:refresh'); buttons.push({
}).catch(error => { label: I18n.t('post.controls.delete_replies.direct_replies', { count: directReplyIds.length }),
popupAjaxError(error); 'class': 'btn-primary',
post.undoDeleteState(); 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) { editPost(post) {
if (!Discourse.User.current()) { if (!this.currentUser) {
return bootbox.alert(I18n.t('post.controls.edit_anonymous')); return bootbox.alert(I18n.t('post.controls.edit_anonymous'));
} } else if (!post.can_edit) {
// check if current user can edit post
if (!post.can_edit) {
return false; return false;
} }
const composer = this.get('composer'), const composer = this.get("composer");
composerModel = composer.get('model'), const composerModel = composer.get("model");
opts = { const opts = {
post: post, post,
action: Composer.EDIT, action: Composer.EDIT,
draftKey: post.get('topic.draft_key'), draftKey: post.get("topic.draft_key"),
draftSequence: post.get('topic.draft_sequence') draftSequence: post.get("topic.draft_sequence")
}; };
// Cancel and reopen the composer for the first post // Cancel and reopen the composer for the first post
if (composerModel && (post.get('firstPost') || composerModel.get('editingFirstPost'))) { if (composerModel && (post.get('firstPost') || composerModel.get('editingFirstPost'))) {
@ -394,10 +404,8 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
toggleBookmark(post) { toggleBookmark(post) {
if (!this.currentUser) { if (!this.currentUser) {
alert(I18n.t("bookmarks.not_bookmarked")); return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
return; } else if (post) {
}
if (post) {
return post.toggleBookmark().catch(popupAjaxError); return post.toggleBookmark().catch(popupAjaxError);
} else { } else {
return this.get("model").toggleBookmark().then(changedIds => { return this.get("model").toggleBookmark().then(changedIds => {
@ -408,14 +416,16 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}, },
jumpToIndex(index) { jumpToIndex(index) {
this._jumpToPostId(this.get('model.postStream.stream')[index-1]); this._jumpToPostId(this.get('model.postStream.stream')[index - 1]);
}, },
jumpToPostPrompt() { jumpToPostPrompt() {
const postText = prompt(I18n.t('topic.progress.jump_prompt_long')); const postText = prompt(I18n.t('topic.progress.jump_prompt_long'));
if (postText === null) { return; } if (postText === null) { return; }
const postNumber = parseInt(postText, 10); const postNumber = parseInt(postText, 10);
if (postNumber === 0) { return; } if (postNumber === 0) { return; }
this._jumpToPostId(this.get('model.postStream').findPostIdForPostNumber(postNumber)); this._jumpToPostId(this.get('model.postStream').findPostIdForPostNumber(postNumber));
}, },
@ -428,6 +438,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
const closest = postStream.closestPostNumberFor(postNumber); const closest = postStream.closestPostNumberFor(postNumber);
postId = postStream.findPostIdForPostNumber(closest); postId = postStream.findPostIdForPostNumber(closest);
} }
this._jumpToPostId(postId); this._jumpToPostId(postId);
}, },
@ -443,96 +454,51 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
this._jumpToPostId(this.get('model.last_read_post_id')); 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() { toggleMultiSelect() {
this.toggleProperty('multiSelect'); this.toggleProperty('multiSelect');
this.appEvents.trigger('post-stream:refresh', { force: true }); this.appEvents.trigger('post-stream:refresh', { force: true });
}, },
finishedEditingTopic() { selectAll() {
if (!this.get('editingTopic')) { return; } this.set('selectedPostIds', [...this.get('model.postStream.stream')]);
this.appEvents.trigger('post-stream:refresh', { force: true });
// 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);
}, },
toggledSelectedPost(post) { deselectAll() {
this.performTogglePost(post); this.set('selectedPostIds', []);
this.appEvents.trigger('post-stream:refresh', { force: true });
}, },
toggledSelectedPostReplies(post) { togglePostSelection(post) {
const selectedReplies = this.get('selectedReplies'); const selected = this.get('selectedPostIds');
if (this.performTogglePost(post)) { selected.includes(post.id) ? selected.removeObject(post.id) : selected.addObject(post.id);
selectedReplies.addObject(post); },
} else {
selectedReplies.removeObject(post); 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 }); this.appEvents.trigger('post-stream:refresh', { force: true });
}, },
deleteSelected() { deleteSelected() {
const user = this.currentUser;
bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), result => { bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), result => {
if (result) { if (result) {
// If all posts are selected, it's the same thing as deleting the topic // If all posts are selected, it's the same thing as deleting the topic
if (this.get('allPostsSelected')) { if (this.get('selectedAllPosts')) return this.deleteTopic();
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());
}
});
Post.deleteMany(this.get('selectedPostIds'));
this.get('model.postStream.posts').forEach(p => this.postSelected(p) && p.setDeletedState(user));
this.send('toggleMultiSelect'); this.send('toggleMultiSelect');
} }
}); });
@ -541,13 +507,48 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
mergePosts() { mergePosts() {
bootbox.confirm(I18n.t("post.merge.confirm", { count: this.get('selectedPostsCount') }), result => { bootbox.confirm(I18n.t("post.merge.confirm", { count: this.get('selectedPostsCount') }), result => {
if (result) { if (result) {
const selectedPosts = this.get('selectedPosts'); Post.mergePosts(this.get("selectedPostIds"));
Post.mergePosts(selectedPosts);
this.send('toggleMultiSelect'); 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) { expandHidden(post) {
return post.expandHidden(); return post.expandHidden();
}, },
@ -665,13 +666,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}, },
retryLoading() { retryLoading() {
const self = this; this.set("retrying", true);
self.set('retrying', true); const rollback = () => this.set("retrying", false);
this.get('model.postStream').refresh().then(function() { this.get("model.postStream").refresh().then(rollback, rollback);
self.set('retrying', false);
}, function() {
self.set('retrying', false);
});
}, },
toggleWiki(post) { toggleWiki(post) {
@ -692,11 +689,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return post.unhide(); return post.unhide();
}, },
changePostOwner(post) {
this.get('selectedPosts').addObject(post);
this.send('changeOwner');
},
convertToPublicTopic() { convertToPublicTopic() {
this.get('content').convertTopic("public"); 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'), hasError: Ember.computed.or('model.notFoundHtml', 'model.message'),
noErrorYet: Ember.computed.not('hasError'), noErrorYet: Ember.computed.not('hasError'),
multiSelectChanged: function() { categories: Ember.computed.alias('site.categoriesList'),
// Deselect all posts when multi select is turned off
if (!this.get('multiSelect')) {
this.send('deselectAll');
}
}.observes('multiSelect'),
deselectPost(post) { selectedPostsCount: Ember.computed.alias('selectedPostIds.length'),
this.get('selectedPosts').removeObject(post);
const selectedReplies = this.get('selectedReplies'); @computed('selectedPostIds', 'model.postStream.posts', 'selectedPostIds.[]', 'model.postStream.posts.[]')
selectedReplies.removeObject(post); 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')); @computed('selectedPostsCount', 'selectedPosts', 'selectedPosts.[]')
if (selectedReply) { selectedReplies.removeObject(selectedReply); } 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) { postSelected(post) {
if (this.get('allPostsSelected')) { return true; } return this.get('selectedAllPost') || this.get('selectedPostIds').includes(post.id);
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;
}, },
loadingHTML: function() { @computed
loadingHTML() {
return spinnerHTML; return spinnerHTML;
}.property(), },
recoverTopic() { recoverTopic() {
this.get('content').recover(); this.get('content').recover();
}, },
deleteTopic() { deleteTopic() {
this.get('content').destroy(Discourse.User.current()); this.get('content').destroy(this.currentUser);
}, },
// Receive notifications for this topic
subscribe() { subscribe() {
// Unsubscribe before subscribing again
this.unsubscribe(); this.unsubscribe();
const refresh = (args) => this.appEvents.trigger('post-stream:refresh', args); const refresh = (args) => this.appEvents.trigger('post-stream:refresh', args);
@ -929,38 +906,22 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}, 500), }, 500),
unsubscribe() { unsubscribe() {
const topicId = this.get('content.id'); // never unsubscribe when navigating from topic to topic
if (!topicId) return; if (!this.get("content.id")) return;
// there is a condition where the view never calls unsubscribe, navigate to a topic from a topic
this.messageBus.unsubscribe('/topic/*'); this.messageBus.unsubscribe('/topic/*');
}, },
// Topic related
reply() { reply() {
this.replyToPost(); 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) { readPosts(topicId, postNumbers) {
const topic = this.get("model"); const topic = this.get("model");
const postStream = topic.get("postStream"); const postStream = topic.get("postStream");
if (topic.get('id') === topicId) { if (topic.get('id') === topicId) {
postStream.get('posts').forEach(post => { 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); post.set('read', true);
this.appEvents.trigger('post-stream:refresh', { id: post.get('id') }); 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 // automatically unpin topics when the user reaches the bottom
const max = _.max(postNumbers); const max = _.max(postNumbers);
if (topic.get("pinned") && max >= topic.get("highest_post_number")) { 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"); const showFooter = this.get("model.postStream.loaded") && this.get("model.postStream.loadedAllPosts");
this.set("application.showFooter", showFooter); 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){ triggerDeletedPost(postId){
const existing = this._identityMap[postId]; const existing = this._identityMap[postId];
if (existing) { if (existing && !existing.deleted_at) {
const url = "/posts/" + postId; const url = "/posts/" + postId;
const store = this.store; const store = this.store;

View File

@ -329,22 +329,17 @@ Post.reopenClass({
}); });
}, },
deleteMany(selectedPosts, selectedReplies) { deleteMany(post_ids) {
return ajax("/posts/destroy_many", { return ajax("/posts/destroy_many", {
type: 'DELETE', type: 'DELETE',
data: { data: { post_ids }
post_ids: selectedPosts.map(function(p) { return p.get('id'); }),
reply_post_ids: selectedReplies.map(function(p) { return p.get('id'); })
}
}); });
}, },
mergePosts(selectedPosts) { mergePosts(post_ids) {
return ajax("/posts/merge_posts", { return ajax("/posts/merge_posts", {
type: 'PUT', type: 'PUT',
data: { post_ids: selectedPosts.map(p => p.get('id')) } data: { post_ids }
}).catch(() => {
self.flash(I18n.t('topic.merge_posts.error'));
}); });
}, },

View File

@ -181,8 +181,9 @@
currentPostChanged=(action "currentPostChanged") currentPostChanged=(action "currentPostChanged")
currentPostScrolled=(action "currentPostScrolled") currentPostScrolled=(action "currentPostScrolled")
bottomVisibleChanged=(action "bottomVisibleChanged") bottomVisibleChanged=(action "bottomVisibleChanged")
selectPost=(action "toggledSelectedPost") togglePostSelection=(action "togglePostSelection")
selectReplies=(action "toggledSelectedPostReplies") selectReplies=(action "selectReplies")
selectBelow=(action "selectBelow")
fillGapBefore=(action "fillGapBefore") fillGapBefore=(action "fillGapBefore")
fillGapAfter=(action "fillGapAfter")}} fillGapAfter=(action "fillGapAfter")}}
{{/unless}} {{/unless}}

View File

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

View File

@ -37,15 +37,31 @@ createWidget('select-post', {
html(attrs) { html(attrs) {
const buttons = []; const buttons = [];
if (attrs.replyCount > 0 && !attrs.selected) { if (!attrs.selected && attrs.post_number > 1) {
buttons.push(this.attach('button', { label: 'topic.multi_select.select_replies', action: 'selectReplies' })); 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'; const key = `topic.multi_select.${attrs.selected ? 'selected' : 'select' }_post`;
buttons.push(this.attach('button', { className: 'select-post', buttons.push(this.attach('button', {
label: selectPostKey, label: key + ".label",
labelOptions: { count: attrs.selectedPostsCount }, title: key + ".title",
action: 'selectPost' })); action: 'togglePostSelection',
className: 'select-post'
}));
return buttons; return buttons;
} }
}); });

View File

@ -7,8 +7,7 @@ require_dependency 'new_post_result_serializer'
class PostsController < ApplicationController 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, :replyIids, :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, :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] 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) render_serialized(post.reply_history(params[:max_replies].to_i, guardian), PostSerializer)
end end
def reply_ids
post = find_post_from_params
render json: post.reply_ids(guardian).to_json
end
def destroy def destroy
post = find_post_from_params post = find_post_from_params
RateLimiter.new(current_user, "delete_post", 3, 1.minute).performed! unless current_user.staff? 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 Post.secured(guardian).where(id: post_ids).includes(:user, :topic).order(:id).to_a
end 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) def revert_to(number)
return if number >= version return if number >= version
post_revision = PostRevision.find_by(post_id: id, number: (number + 1)) post_revision = PostRevision.find_by(post_id: id, number: (number + 1))

View File

@ -1858,7 +1858,18 @@ en:
multi_select: multi_select:
select: 'select' select: 'select'
selected: 'selected ({{count}})' 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 delete: delete selected
cancel: cancel selecting cancel: cancel selecting
select_all: select all select_all: select all
@ -1950,11 +1961,14 @@ en:
share: "share a link to this post" share: "share a link to this post"
more: "More" more: "More"
delete_replies: delete_replies:
confirm: confirm: "Do you also want to delete the replies to this post?"
one: "Do you also want to delete the direct reply to this post?" direct_replies:
other: "Do you also want to delete the {{count}} direct replies to this post?" one: "Yes, and 1 direct reply"
yes_value: "Yes, delete the replies too" other: "Yes, and {{count}} direct replies"
no_value: "No, just this post" all_replies:
one: "Yes, and 1 reply"
other: "Yes, and all {{count}} replies"
just_the_post: "No, just this post"
admin: "post admin actions" admin: "post admin actions"
wiki: "Make Wiki" wiki: "Make Wiki"
unwiki: "Remove Wiki" unwiki: "Remove Wiki"
@ -2051,11 +2065,10 @@ en:
delete: delete:
confirm: confirm:
one: "Are you sure you want to delete that post?" 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: merge:
confirm: confirm:
one: "Are you sure you want to merge those posts?"
other: "Are you sure you want to merge those {{count}} posts?" other: "Are you sure you want to merge those {{count}} posts?"
revisions: revisions:

View File

@ -434,6 +434,7 @@ Discourse::Application.routes.draw do
get "private-posts" => "posts#latest", id: "private_posts" get "private-posts" => "posts#latest", id: "private_posts"
get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/by_number/:topic_id/:post_number" => "posts#by_number"
get "posts/:id/reply-history" => "posts#reply_history" 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/deleted" => "posts#deleted_posts", constraints: { username: USERNAME_ROUTE_FORMAT }
get "posts/:username/flagged" => "posts#flagged_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 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 describe 'urls' do
it 'no-ops for empty list' do it 'no-ops for empty list' do
expect(Post.urls([])).to eq({}) 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', { moduleFor("controller:topic", "controller:topic", {
needs: ['controller:modal', 'controller:composer', 'controller:application'], needs: ["controller:composer", "controller:application"],
beforeEach() { 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'; QUnit.test("editTopic", function(assert) {
import AppEvents from 'discourse/lib/app-events'; const model = Topic.create();
const controller = this.subject({ model });
var buildTopic = function() { assert.not(controller.get("editingTopic"), "we are not editing by default");
return Topic.create({
title: "Qunit Test Topic",
participants: [
{id: 1234,
post_count: 4,
username: "eviltrout"}
]
});
};
controller.set("model.details.can_edit", false);
controller.send("editTopic");
QUnit.test("editingMode", function(assert) { assert.not(controller.get("editingTopic"), "calling editTopic doesn't enable editing unless the user can edit");
var topic = buildTopic(),
topicController = this.subject({model: topic});
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); assert.ok(controller.get("editingTopic"), "calling editTopic enables editing if the user can edit");
topicController.send('editTopic'); assert.equal(controller.get("buffered.title"), model.get("title"));
assert.ok(!topicController.get('editingTopic'), "calling editTopic doesn't enable editing unless the user can edit"); assert.equal(controller.get("buffered.category_id"), model.get("category_id"));
topicController.set('model.details.can_edit', true); controller.send("cancelEditingTopic");
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'));
topicController.send('cancelEditingTopic'); assert.not(controller.get("editingTopic"), "cancelling edit mode reverts the property value");
assert.ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value");
}); });
QUnit.test("toggledSelectedPost", function(assert) { QUnit.test("toggleMultiSelect", function(assert) {
var tc = this.subject({ model: buildTopic() }), const model = Topic.create();
post = Discourse.Post.create({id: 123, post_number: 2}), const controller = this.subject({ model });
postStream = tc.get('model.postStream');
postStream.appendPost(post); assert.not(controller.get("multiSelect"), "multi selection mode is disabled by default");
postStream.appendPost(Discourse.Post.create({id: 124, post_number: 3}));
assert.blank(tc.get('selectedPosts'), "there are no selected posts by default"); controller.get("selectedPostIds").pushObject(1);
assert.equal(tc.get('selectedPostsCount'), 0, "there is a selected post count of 0"); assert.equal(controller.get("selectedPostIds.length"), 1);
assert.ok(!tc.postSelected(post), "the post is not selected by default");
tc.send('toggledSelectedPost', post); controller.send("toggleMultiSelect");
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");
tc.send('toggledSelectedPost', post); assert.ok(controller.get("multiSelect"), "calling 'toggleMultiSelect' once enables multi selection mode");
assert.ok(!tc.postSelected(post), "the post is no longer selected"); 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) { QUnit.test("selectedPosts", function(assert) {
var tc = this.subject({model: buildTopic(), appEvents: AppEvents.create()}), const postStream = { posts: [{ id: 1 }, { id: 2 }, { id: 3 }] };
post = Discourse.Post.create({id: 123, post_number: 2}), const model = Topic.create({ postStream });
postStream = tc.get('model.postStream'); const controller = this.subject({ model });
postStream.appendPost(post); controller.set("selectedPostIds", [1, 2, 42]);
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");
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) { QUnit.test("selectedAllPosts", function(assert) {
var topic = buildTopic(), const postStream = { stream: [1, 2, 3] };
tc = this.subject({model: topic}), const model = Topic.create({ postStream });
post = Discourse.Post.create({id: 123, post_number: 2}), const controller = this.subject({ model });
postStream = tc.get('model.postStream');
topic.set('posts_count', 1); controller.set("selectedPostIds", [1, 2]);
postStream.appendPost(post);
assert.ok(!tc.get('allPostsSelected'), "all posts are not selected by default");
tc.send('toggledSelectedPost', post); assert.not(controller.get("selectedAllPosts"), "not all posts are selected");
assert.ok(tc.get('allPostsSelected'), "all posts are selected if we select the only post");
tc.send('toggledSelectedPost', post); controller.get("selectedPostIds").pushObject(3);
assert.ok(!tc.get('allPostsSelected'), "the posts are no longer automatically selected");
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) { QUnit.test("selectedPostsUsername", function(assert) {
var topic = buildTopic(), const postStream = {
tc = this.subject({ model: topic, appEvents: AppEvents.create() }), posts: [
p1 = Discourse.Post.create({id: 1, post_number: 1, reply_count: 1}), { id: 1, username: "gary" },
p2 = Discourse.Post.create({id: 2, post_number: 2}), { id: 2, username: "gary" },
p3 = Discourse.Post.create({id: 2, post_number: 3, reply_to_post_number: 1}); { id: 3, username: "lili" },
],
stream: [1, 2, 3]
};
assert.ok(!tc.postSelected(p3), "replies are not selected by default"); const model = Topic.create({ postStream });
tc.send('toggledSelectedPostReplies', p1); const controller = this.subject({ model });
assert.ok(tc.postSelected(p1), "it selects the post"); const selectedPostIds = controller.get("selectedPostIds");
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");
// If we deselected the post whose replies are selected... assert.equal(controller.get("selectedPostsUsername"), undefined, "no username when no selected posts");
tc.send('toggledSelectedPost', p1);
assert.ok(!tc.postSelected(p1), "it deselects the post");
assert.ok(!tc.postSelected(p3), "it deselects the replies too");
// If we deselect a reply, it should deselect the parent's replies selected attribute. Weird but what else would make sense? selectedPostIds.pushObject(1);
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");
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 } ]); 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.post('/user_badges', () => response(200, fixturesByUrl['/user_badges']));
this.delete('/user_badges/:badge_id', success); 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 { flushMap } from 'discourse/models/store';
import { clearRewrites } from 'discourse/lib/url'; import { clearRewrites } from 'discourse/lib/url';
export function currentUser() { export function currentUser() {
return Discourse.User.create(sessionFixtures['/session/current.json'].current_user); 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");
});