Many enhancements to the flagging / suspending interface.

This commit is contained in:
Robin Ward 2018-01-30 16:31:29 -05:00
parent f7df68c9a3
commit 8ff4104555
18 changed files with 255 additions and 120 deletions

View File

@ -4,8 +4,6 @@ import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
adminTools: Ember.inject.service(),
expanded: false,
suspended: false,
tagName: 'div',
classNameBindings: [
':flagged-post',
@ -21,12 +19,7 @@ export default Ember.Component.extend({
},
removeAfter(promise) {
return promise.then(() => {
this.attrs.removePost();
}).catch(error => {
if (error._discourse_displayed) { return; }
bootbox.alert(I18n.t("admin.flags.error"));
});
return promise.then(() => this.attrs.removePost());
},
_spawnModal(name, model, modalClass) {
@ -36,7 +29,7 @@ export default Ember.Component.extend({
actions: {
removeAfter(promise) {
this.removeAfter(promise);
return this.removeAfter(promise);
},
disagree() {
@ -58,18 +51,6 @@ export default Ember.Component.extend({
filter: 'post',
post_id: this.get('flaggedPost.id')
});
},
showSuspendModal() {
let post = this.get('flaggedPost');
let user = post.get('user');
this.get('adminTools').showSuspendModal(
user,
{
post,
successCallback: result => this.set('suspended', result.suspended)
}
);
}
}
});

View File

@ -0,0 +1,32 @@
import computed from 'ember-addons/ember-computed-decorators';
const ACTIONS = ['delete', 'edit', 'none'];
export default Ember.Component.extend({
postAction: null,
postEdit: null,
@computed
penaltyActions() {
return ACTIONS.map(id => {
return { id, name: I18n.t(`admin.user.penalty_post_${id}`) };
});
},
editing: Ember.computed.equal('postAction', 'edit'),
actions: {
penaltyChanged() {
let postAction = this.get('postAction');
// If we switch to edit mode, jump to the edit textarea
if (postAction === 'edit') {
Ember.run.scheduleOnce('afterRender', () => {
let $elem = this.$();
let body = $elem.closest('.modal-body');
body.scrollTop(body.height());
$elem.find('.post-editor').focus();
});
}
}
}
});

View File

@ -1,26 +1,14 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import computed from 'ember-addons/ember-computed-decorators';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import PenaltyController from 'admin/mixins/penalty-controller';
export default Ember.Controller.extend(ModalFunctionality, {
export default Ember.Controller.extend(PenaltyController, {
silenceUntil: null,
reason: null,
message: null,
silencing: false,
user: null,
post: null,
successCallback: null,
onShow() {
this.setProperties({
silenceUntil: null,
reason: null,
message: null,
silencing: false,
loadingUser: true,
post: null,
successCallback: null,
});
this.resetModal();
this.setProperties({ silenceUntil: null, silencing: false });
},
@computed('silenceUntil', 'reason', 'silencing')
@ -33,18 +21,16 @@ export default Ember.Controller.extend(ModalFunctionality, {
if (this.get('submitDisabled')) { return; }
this.set('silencing', true);
this.get('user').silence({
silenced_till: this.get('silenceUntil'),
reason: this.get('reason'),
message: this.get('message'),
post_id: this.get('post.id')
}).then(result => {
this.send('closeModal');
let callback = this.get('successCallback');
if (callback) {
callback(result);
}
}).catch(popupAjaxError).finally(() => this.set('silencing', false));
this.penalize(() => {
return this.get('user').silence({
silenced_till: this.get('silenceUntil'),
reason: this.get('reason'),
message: this.get('message'),
post_id: this.get('post.id'),
post_action: this.get('postAction'),
post_edit: this.get('postEdit')
});
}).finally(() => this.set('silencing', false));
}
}
});

View File

@ -1,26 +1,13 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import computed from 'ember-addons/ember-computed-decorators';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import PenaltyController from 'admin/mixins/penalty-controller';
export default Ember.Controller.extend(ModalFunctionality, {
export default Ember.Controller.extend(PenaltyController, {
suspendUntil: null,
reason: null,
message: null,
suspending: false,
user: null,
post: null,
successCallback: null,
onShow() {
this.setProperties({
suspendUntil: null,
reason: null,
message: null,
suspending: false,
loadingUser: true,
post: null,
successCallback: null,
});
this.resetModal();
this.setProperties({ suspendUntil: null, suspending: false });
},
@computed('suspendUntil', 'reason', 'suspending')
@ -33,19 +20,17 @@ export default Ember.Controller.extend(ModalFunctionality, {
if (this.get('submitDisabled')) { return; }
this.set('suspending', true);
this.get('user').suspend({
suspend_until: this.get('suspendUntil'),
reason: this.get('reason'),
message: this.get('message'),
post_id: this.get('post.id')
}).then(result => {
this.send('closeModal');
let callback = this.get('successCallback');
if (callback) {
callback(result);
}
}).catch(popupAjaxError).finally(() => this.set('suspending', false));
this.penalize(() => {
return this.get('user').suspend({
suspend_until: this.get('suspendUntil'),
reason: this.get('reason'),
message: this.get('message'),
post_id: this.get('post.id'),
post_action: this.get('postAction'),
post_edit: this.get('postEdit')
});
}).finally(() => this.set('suspending', false));
}
}
});

View File

@ -0,0 +1,41 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.Mixin.create(ModalFunctionality, {
reason: null,
message: null,
postEdit: null,
postAction: null,
user: null,
post: null,
successCallback: null,
resetModal() {
this.setProperties({
reason: null,
message: null,
loadingUser: true,
post: null,
postEdit: null,
postAction: 'delete',
before: null,
successCallback: null
});
},
penalize(cb) {
let before = this.get('before');
let promise = before ? before() : Ember.RSVP.resolve();
return promise
.then(() => cb())
.then(result => {
this.send('closeModal');
let callback = this.get('successCallback');
if (callback) {
callback(result);
}
})
.catch(popupAjaxError);
}
});

View File

@ -52,7 +52,10 @@ export default Ember.Service.extend({
modalClass: `${type}-user-modal`
});
if (opts.post) {
controller.set('post', opts.post);
controller.setProperties({
post: opts.post,
postEdit: opts.post.get('raw')
});
}
return (user.adminUserView ?
@ -62,6 +65,7 @@ export default Ember.Service.extend({
controller.setProperties({
user: loadedUser,
loadingUser: false,
before: opts.before,
successCallback: opts.successCallback
});
});

View File

@ -68,12 +68,6 @@
{{flag-user-lists flaggedPost=flaggedPost showResolvedBy=showResolvedBy}}
{{#if suspended}}
<div class='suspended-message'>
{{i18n "admin.flags.suspended_for_post"}}
</div>
{{/if}}
<div class='flagged-post-controls'>
{{#if canAct}}
{{admin-agree-flag-dropdown
@ -106,15 +100,6 @@
{{admin-delete-flag-dropdown
post=flaggedPost
removeAfter=(action "removeAfter")}}
{{#unless suspended}}
{{d-button
class="btn-danger suspend-user"
icon="ban"
label="admin.flags.suspend_user"
title="admin.flags.suspend_user_title"
action=(action "showSuspendModal")}}
{{/unless}}
{{/if}}
{{d-button

View File

@ -0,0 +1,16 @@
<div class='penalty-post-controls'>
<label>
<div class='penalty-post-label'>
{{{i18n 'admin.user.penalty_post_actions'}}}
</div>
</label>
{{combo-box value=postAction content=penaltyActions onSelect=(action "penaltyChanged")}}
</div>
{{#if editing}}
<div class='penalty-post-edit'>
{{textarea
value=postEdit
class="post-editor"}}
</div>
{{/if}}

View File

@ -12,6 +12,12 @@
</div>
{{silence-details reason=reason message=message}}
{{#if post}}
{{penalty-post-action
post=post
postAction=postAction
postEdit=postEdit}}
{{/if}}
{{/conditional-loading-spinner}}

View File

@ -13,6 +13,13 @@
</div>
{{suspension-details reason=reason message=message}}
{{#if post}}
{{penalty-post-action
post=post
postAction=postAction
postEdit=postEdit}}
{{/if}}
{{else}}
<div class='cant-suspend'>
{{i18n "admin.user.cant_suspend"}}

View File

@ -54,9 +54,11 @@ export function throwAjaxError(undoCallback) {
}
export function popupAjaxError(error) {
if (error && error._discourse_displayed) { return; }
bootbox.alert(extractError(error));
error._discourse_displayed = true;
// We re-throw in a catch to not swallow the exception
throw error;
}

View File

@ -55,6 +55,22 @@ export default DropdownSelectBox.extend({
label: I18n.t("admin.flags.agree_flag"),
});
content.push({
icon: 'ban',
id: 'confirm-agree-suspend',
description: I18n.t('admin.flags.agree_flag_suspend_title'),
action: () => this.send("showSuspendModal"),
label: I18n.t("admin.flags.agree_flag_suspend"),
});
content.push({
icon: 'microphone-slash',
id: 'confirm-agree-silence',
description: I18n.t('admin.flags.agree_flag_silence_title'),
action: () => this.send("showSilenceModal"),
label: I18n.t("admin.flags.agree_flag_silence"),
});
if (canDeleteSpammer) {
content.push({
title: I18n.t("admin.flags.delete_spammer_title"),
@ -79,6 +95,28 @@ export default DropdownSelectBox.extend({
this.attrs.removeAfter(spammerDetails.deleteUser());
},
showSuspendModal() {
let post = this.get('post');
let user = post.get('user');
this.get('adminTools').showSuspendModal(user, {
post,
before: () => {
return this.attrs.removeAfter(post.agreeFlags('suspended'));
}
});
},
showSilenceModal() {
let post = this.get('post');
let user = post.get('user');
this.get('adminTools').showSilenceModal(user, {
post,
before: () => {
return this.attrs.removeAfter(post.agreeFlags('silenced'));
}
});
},
perform(action) {
let flaggedPost = this.get("post");
this.attrs.removeAfter(flaggedPost.agreeFlags(action));

View File

@ -21,3 +21,13 @@
float: right;
}
}
.modal-body {
.penalty-post-edit {
margin-top: 1em;
textarea {
height: 10em;
}
}
}

View File

@ -93,6 +93,8 @@ class Admin::UsersController < Admin::AdminController
suspended_at: DateTime.now
)
perform_post_action
render_json_dump(
suspension: {
suspended: true,
@ -297,6 +299,7 @@ class Admin::UsersController < Admin::AdminController
user_history_id: silencer.user_history.id
)
end
perform_post_action
render_json_dump(
silence: {
@ -467,6 +470,27 @@ class Admin::UsersController < Admin::AdminController
private
def perform_post_action
return unless params[:post_id].present? &&
params[:post_action].present?
if post = Post.where(id: params[:post_id]).first
case params[:post_action]
when 'delete'
PostDestroyer.new(current_user, post).destroy
when 'edit'
revisor = PostRevisor.new(post)
# Take what the moderator edited in as gospel
revisor.revise!(
current_user,
{ raw: params[:post_edit] },
skip_validations: true, skip_revision: true
)
end
end
end
def fetch_user
@user = User.find_by(id: params[:user_id])
end

View File

@ -2695,7 +2695,12 @@ en:
agree_flag_hide_post_title: "Hide this post and automatically send the user a message urging them to edit it."
agree_flag_restore_post: "Agree and Restore Post"
agree_flag_restore_post_title: "Restore the post so that all users can see it."
agree_flag: "Agree and Keep Post"
agree_flag_suspend: "Suspend User"
agree_flag_suspend_title: "Agree with flag and suspend the user."
agree_flag_silence: "Silence User"
agree_flag_silence_title: "Agree with flag and silence the user."
agree_flag: "Keep Post"
agree_flag_title: "Agree with flag and keep the post unchanged."
ignore_flag: "Ignore"
ignore_flag_title: "Remove this flag; it requires no action at this time."
@ -2729,7 +2734,6 @@ en:
system: "System"
error: "Something went wrong"
reply_message: "Reply"
suspended_for_post: "The user was suspended for this post."
no_results: "There are no flagged posts."
topic_flagged: "This <strong>topic</strong> has been flagged."
show_full: "show full post"
@ -3392,6 +3396,10 @@ en:
suspended_until: "(until %{until})"
cant_suspend: "This user cannot be suspended."
delete_all_posts: "Delete all posts"
penalty_post_actions: "What would you like to do with the associated post?"
penalty_post_delete: "Delete the post"
penalty_post_edit: "Edit the post"
penalty_post_none: "Do nothing"
# keys ending with _MF use message format, see https://meta.discourse.org/t/message-format-support-for-localization/7035 for details
delete_all_posts_confirm_MF: "You are about to delete {POSTS, plural, one {1 post} other {# posts}} and {TOPICS, plural, one {1 topic} other {# topics}}. Are you sure?"

View File

@ -31,6 +31,7 @@ module FlagQuery
posts = SqlBuilder.new("
SELECT p.id,
p.cooked,
p.raw,
p.user_id,
p.topic_id,
p.post_number,

View File

@ -148,24 +148,42 @@ describe Admin::UsersController do
expect(log.details).to match(/because I said so/)
end
it "can have an associated post" do
post = Fabricate(:post)
put(
:suspend,
params: {
user_id: user.id,
context "with an associated post" do
let(:post) { Fabricate(:post) }
let(:suspend_params) do
{ user_id: user.id,
suspend_until: 5.hours.from_now,
reason: "because of this post",
post_id: post.id,
format: :json
}
)
expect(response).to be_success
format: :json }
end
log = UserHistory.where(target_user_id: user.id).order('id desc').first
expect(log).to be_present
expect(log.post_id).to eq(post.id)
it "can have an associated post" do
put(:suspend, params: suspend_params)
expect(response).to be_success
log = UserHistory.where(target_user_id: user.id).order('id desc').first
expect(log).to be_present
expect(log.post_id).to eq(post.id)
end
it "can delete an associated post" do
put(:suspend, params: suspend_params.merge(post_action: 'delete'))
post.reload
expect(post.deleted_at).to be_present
expect(response).to be_success
end
it "can edit an associated post" do
put(:suspend, params: suspend_params.merge(
post_action: 'edit',
post_edit: 'this is the edited content'
))
post.reload
expect(post.deleted_at).to be_blank
expect(post.raw).to eq("this is the edited content")
expect(response).to be_success
end
end
it "can send a message to the user" do

View File

@ -104,15 +104,6 @@ QUnit.test("flagged posts - delete + deleteSpammer", assert => {
});
});
QUnit.test("flagged posts - suspend", assert => {
visit("/admin/flags/active");
click('.suspend-user');
andThen(() => {
assert.equal(find('.suspend-user-modal:visible').length, 1);
assert.equal(find('.suspend-user-modal .cant-suspend').length, 1);
});
});
QUnit.test("topics with flags", assert => {
visit("/admin/flags/topics");
andThen(() => {