New Feature: Staff can choose to "Take Action" when flagging to immediately reach hiding

thresholds.
This commit is contained in:
Robin Ward 2013-05-31 17:38:28 -04:00
parent 476ffcc627
commit 545dbfc07e
19 changed files with 194 additions and 135 deletions

View file

@ -0,0 +1,48 @@
/**
Supports logic for flags in the modal
@class FlagActionTypeController
@extends Discourse.ObjectController
@namespace Discourse
@module Discourse
**/
Discourse.FlagActionTypeController = Discourse.ObjectController.extend({
needs: ['flag'],
message: Em.computed.alias('controllers.flag.message'),
customPlaceholder: function(){
return Em.String.i18n("flagging.custom_placeholder_" + this.get('name_key'));
}.property('name_key'),
formattedName: function(){
return this.get('name').replace("{{username}}", this.get('controllers.flag.username'));
}.property('name'),
selected: function() {
return this.get('model') === this.get('controllers.flag.selected');
}.property('controllers.flag.selected'),
showMessageInput: Em.computed.and('is_custom_flag', 'selected'),
showDescription: Em.computed.not('showMessageInput'),
customMessageLengthClasses: function() {
return (this.get('message.length') < Discourse.PostActionType.MIN_MESSAGE_LENGTH) ? "too-short" : "ok"
}.property('message.length'),
customMessageLength: function() {
var len = this.get('message.length') || 0;
var minLen = Discourse.PostActionType.MIN_MESSAGE_LENGTH;
if (len === 0) {
return Em.String.i18n("flagging.custom_message.at_least", { n: minLen });
} else if (len < minLen) {
return Em.String.i18n("flagging.custom_message.more", { n: minLen - len });
} else {
return Em.String.i18n("flagging.custom_message.left", {
n: Discourse.PostActionType.MAX_MESSAGE_LENGTH - len
});
}
}.property('message.length')
});

View file

@ -9,70 +9,58 @@
**/
Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, {
// trick to bind user / post to flag
boundFlags: function() {
var _this = this;
var original = this.get('flagsAvailable');
if(original){
return $.map(original, function(v){
var b = Discourse.BoundPostActionType.create(v);
b.set('post', _this.get('model'));
return b;
});
}
}.property('flagsAvailable.@each'),
changePostActionType: function(action) {
if (this.get('postActionTypeId') === action.id) return false;
this.get('boundFlags').setEach('selected', false);
action.set('selected', true);
this.set('postActionTypeId', action.id);
this.set('isCustomFlag', action.is_custom_flag);
this.set('selected', action);
return false;
},
showSubmit: function() {
if (this.get('postActionTypeId')) {
if (this.get('isCustomFlag')) {
var m = this.get('selected.message');
return m && m.length >= 10 && m.length <= 500;
} else {
return true;
}
submitEnabled: function() {
var selected = this.get('selected');
if (!selected) return false;
if (selected.get('is_custom_flag')) {
var len = this.get('message.length') || 0;
return len >= Discourse.PostActionType.MIN_MESSAGE_LENGTH &&
len <= Discourse.PostActionType.MAX_MESSAGE_LENGTH;
}
return false;
}.property('isCustomFlag', 'selected.customMessageLength', 'postActionTypeId'),
return true;
}.property('selected.is_custom_flag', 'message.length'),
submitDisabled: Em.computed.not('submitEnabled'),
// Staff accounts can "take action"
canTakeAction: function() {
// We can only take actions on non-custom flags
if (this.get('selected.is_custom_flag')) return false;
return Discourse.User.current('staff');
}.property('selected.is_custom_flag'),
submitText: function(){
var action = this.get('selected');
if (this.get('selected.is_custom_flag')) {
return Em.String.i18n("flagging.notify_action");
} else {
return Em.String.i18n("flagging.action");
}
}.property('selected'),
}.property('selected.is_custom_flag'),
createFlag: function() {
var _this = this;
takeAction: function() {
this.createFlag({takeAction: true})
this.set('hidden', true);
},
var action = this.get('selected');
var postAction = this.get('actionByName.' + (action.get('name_key')));
createFlag: function(opts) {
var flagController = this;
var postAction = this.get('actionByName.' + this.get('selected.name_key'));
var params = this.get('selected.is_custom_flag') ? {message: this.get('message')} : {}
var actionType = Discourse.Site.instance().postActionTypeById(this.get('postActionTypeId'));
if (postAction) {
postAction.act({
message: action.get('message')
}).then(function() {
return $('#discourse-modal').modal('hide');
}, function(errors) {
return _this.displayErrors(errors);
});
}
return false;
if (opts) params = $.extend(params, opts);
postAction.act(params).then(function() {
flagController.closeModal();
}, function(errors) {
flagController.displayErrors(errors);
});
}
});

View file

@ -8,6 +8,16 @@
**/
Discourse.ModalController = Discourse.Controller.extend({
/**
Close the modal.
@method closeModal
**/
closeModal: function() {
// Currently uses jQuery to hide it.
$('#discourse-modal').modal('hide');
}
});

View file

@ -21,6 +21,16 @@ Discourse.ModalFunctionality = Em.Mixin.create({
message: message,
messageClass: messageClass
}));
},
/**
Close the modal.
@method closeModal
**/
closeModal: function() {
// Currently uses jQuery to hide it.
this.get('controllers.modal').closeModal();
}
});

View file

@ -37,6 +37,8 @@ Discourse.ActionSummary = Discourse.Model.extend({
// Perform this action
act: function(opts) {
if (!opts) opts = {};
var action = this.get('actionType.name_key');
// Mark it as acted
@ -63,7 +65,8 @@ Discourse.ActionSummary = Discourse.Model.extend({
data: {
id: this.get('post.id'),
post_action_type_id: this.get('id'),
message: (opts ? opts.message : void 0) || ""
message: opts.message,
take_action: opts.takeAction
}
}).then(null, function (error) {
actionSummary.removeAction();

View file

@ -6,31 +6,9 @@
@namespace Discourse
@module Discourse
**/
Discourse.PostActionType = Discourse.Model.extend({
});
Discourse.PostActionType = Discourse.Model.extend({});
Discourse.BoundPostActionType = Discourse.PostActionType.extend({
customPlaceholder: function(){
return Em.String.i18n("flagging.custom_placeholder_" + this.get('name_key'));
}.property('name_key'),
formattedName: function(){
return this.get('name').replace("{{username}}", this.get('post.username'));
}.property('name'),
messageChanged: function() {
var len, message, minLen, _ref;
minLen = 10;
len = ((_ref = this.get('message')) ? _ref.length : void 0) || 0;
this.set("customMessageLengthClasses", "too-short custom-message-length");
if (len === 0) {
message = Em.String.i18n("flagging.custom_message.at_least", { n: minLen });
} else if (len < minLen) {
message = Em.String.i18n("flagging.custom_message.more", { n: minLen - len });
} else {
message = Em.String.i18n("flagging.custom_message.left", { n: 500 - len });
this.set("customMessageLengthClasses", "ok custom-message-length");
}
this.set("customMessageLength", message);
}.observes("message")
});
Discourse.PostActionType.reopenClass({
MIN_MESSAGE_LENGTH: 10,
MAX_MESSAGE_LENGTH: 500
})

View file

@ -13,9 +13,7 @@ Discourse.TopicRoute = Discourse.Route.extend({
showFlags: function(post) {
Discourse.Route.showModal(this, 'flag', post);
this.controllerFor('flag').setProperties({
postActionTypeId: null
});
this.controllerFor('flag').setProperties({ selected: null });
},
showAutoClose: function() {

View file

@ -1,8 +1,8 @@
<div class="modal-body">
<div class="modal-body">
<form>
{{view Discourse.ArchetypeOptionsView archetypeBinding="view.archetype"}}
</form>
</div>
<div class="modal-footer">
<button class='btn btn-primary' data-dismiss="modal">{{i18n post.archetypes.save}}</button>
</div>
<button class='btn btn-primary' {{action closeModal}}>{{i18n post.archetypes.save}}</button>
</div>

View file

@ -1,36 +1,30 @@
<div class="modal-body flag-modal">
{{#if flagsAvailable}}
<form>
{{#each boundFlags}}
<div class='controls'>
<label class='radio'>
<input type='radio' id="radio_{{unbound name_key}}" {{action changePostActionType this}} name='post_action_type_index'> <strong>{{formattedName}}</strong>
{{#if is_custom_flag}}
{{#unless selected}}
<div class='description'>{{{description}}}</div>
{{/unless}}
{{else}}
{{#if description}}
<div class='description'>{{{description}}}</div>
{{/if}}
{{/if}}
</label>
{{#if is_custom_flag}}
{{#if selected}}
{{textarea name="message" class="flag-message" placeholder=customPlaceholder value=message}}
<div {{bindAttr class="customMessageLengthClasses"}}>{{customMessageLength}}</div>
{{/if}}
<form>
{{#each flagsAvailable itemController="flagActionType"}}
<div class='controls'>
<label class='radio'>
<input type='radio' id="radio_{{unbound name_key}}" {{action changePostActionType this}} name='post_action_type_index'> <strong>{{formattedName}}</strong>
{{#if showDescription}}
<div class='description'>{{{description}}}</div>
{{/if}}
</div>
{{/each}}
</form>
{{else}}
{{i18n flagging.cant}}
</label>
{{#if showMessageInput}}
{{textarea name="message" class="flag-message" placeholder=customPlaceholder value=message}}
<div {{bindAttr class=":custom-message-length customMessageLengthClasses"}}>{{customMessageLength}}</div>
{{/if}}
</div>
{{else}}
{{i18n flagging.cant}}
{{/each}}
</form>
</div>
<div class="modal-footer">
<button class='btn btn-primary' {{action createFlag}} {{bindAttr disabled="submitDisabled"}}>{{submitText}}</button>
{{#if canTakeAction}}
<button class='btn btn-danger' {{action takeAction}} {{bindAttr disabled="submitDisabled"}}>{{i18n flagging.take_action}}</button>
{{/if}}
</div>
{{#if showSubmit}}
<div class="modal-footer">
<button class='btn btn-primary' {{action createFlag}}>{{submitText}}</button>
</div>
{{/if}}

View file

@ -17,7 +17,7 @@
</div>
<div class="modal-footer">
{{#if finished}}
<button class='btn btn-primary' data-dismiss="modal">{{i18n close}}</button>
<button class='btn btn-primary' {{action closeModal}}>{{i18n close}}</button>
{{else}}
<button class='btn btn-primary' {{bindAttr disabled="disabled"}} {{action createInvite}}>{{buttonTitle}}</button>
{{/if}}

View file

@ -17,7 +17,7 @@
</div>
<div class="modal-footer">
{{#if finished}}
<button class='btn btn-primary' data-dismiss="modal">{{i18n close}}</button>
<button class='btn btn-primary' {{action closeModal}}>{{i18n close}}</button>
{{else}}
<button class='btn btn-primary' {{bindAttr disabled="disabled"}} {{action invite}}>{{buttonTitle}}</button>
{{/if}}

View file

@ -1,5 +1,5 @@
<div class="modal-header">
<a class="close" data-dismiss="modal"><i class='icon-remove icon'></i></a>
<a class="close" {{action closeModal}}><i class='icon-remove icon'></i></a>
<h3>{{title}}</h3>
</div>
<div id='modal-alert'></div>

View file

@ -7,5 +7,5 @@
{{/if}}
</div>
<div class="modal-footer">
<button class='btn btn-primary' data-dismiss="modal">{{i18n close}}</button>
<button class='btn btn-primary' {{action closeModal}}>{{i18n close}}</button>
</div>

View file

@ -9,7 +9,11 @@ class PostActionsController < ApplicationController
def create
guardian.ensure_post_can_act!(@post, PostActionType.types[@post_action_type_id])
post_action = PostAction.act(current_user, @post, @post_action_type_id, params[:message])
args = {}
args[:message] = params[:message] if params[:message].present?
args[:take_action] = true if guardian.is_staff? and params[:take_action] == 'true'
post_action = PostAction.act(current_user, @post, @post_action_type_id, args)
if post_action.blank? || post_action.errors.present?
render_json_error(post_action)

View file

@ -8,7 +8,7 @@ class PostAction < ActiveRecord::Base
include RateLimiter::OnCreateRecord
include Trashable
attr_accessible :post_action_type_id, :post_id, :user_id, :post, :user, :post_action_type, :message, :related_post_id
attr_accessible :post_action_type_id, :post_id, :user_id, :post, :user, :post_action_type, :message, :related_post_id, :staff_took_action
belongs_to :post
belongs_to :user
@ -70,11 +70,11 @@ class PostAction < ActiveRecord::Base
update_flagged_posts_count
end
def self.act(user, post, post_action_type_id, message = nil)
def self.act(user, post, post_action_type_id, opts={})
begin
title, target_usernames, target_group_names, subtype, body = nil
if message
if opts[:message]
[:notify_moderators, :notify_user].each do |k|
if post_action_type_id == PostActionType.types[k]
if k == :notify_moderators
@ -85,7 +85,7 @@ class PostAction < ActiveRecord::Base
title = I18n.t("post_action_types.#{k}.email_title",
title: post.topic.title)
body = I18n.t("post_action_types.#{k}.email_body",
message: message,
message: opts[:message],
link: "#{Discourse.base_url}#{post.url}")
subtype = k == :notify_moderators ? TopicSubtype.notify_moderators : TopicSubtype.notify_user
end
@ -103,10 +103,12 @@ class PostAction < ActiveRecord::Base
raw: body
).create.id
end
create( post_id: post.id,
user_id: user.id,
post_action_type_id: post_action_type_id,
message: message,
message: opts[:message],
staff_took_action: opts[:take_action] || false,
related_post_id: related_post_id )
rescue ActiveRecord::RecordNotUnique
# can happen despite being .create
@ -177,13 +179,13 @@ class PostAction < ActiveRecord::Base
# can weigh flags differently.
def self.flag_counts_for(post_id)
flag_counts = exec_sql("SELECT SUM(CASE
WHEN pa.deleted_at IS NULL AND u.admin THEN :flags_required_to_hide_post
WHEN pa.deleted_at IS NULL AND (NOT u.admin) THEN 1
WHEN pa.deleted_at IS NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
WHEN pa.deleted_at IS NULL AND (NOT pa.staff_took_action) THEN 1
ELSE 0
END) AS new_flags,
SUM(CASE
WHEN pa.deleted_at IS NOT NULL AND u.admin THEN :flags_required_to_hide_post
WHEN pa.deleted_at IS NOT NULL AND (NOT u.admin) THEN 1
WHEN pa.deleted_at IS NOT NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
WHEN pa.deleted_at IS NOT NULL AND (NOT pa.staff_took_action) THEN 1
ELSE 0
END) AS old_flags
FROM post_actions AS pa
@ -234,7 +236,8 @@ class PostAction < ActiveRecord::Base
reason = old_flags > 0 ? Post.hidden_reasons[:flag_threshold_reached_again] : Post.hidden_reasons[:flag_threshold_reached]
Post.update_all(["hidden = true, hidden_reason_id = COALESCE(hidden_reason_id, ?)", reason], id: post_id)
Topic.update_all({ visible: false },
["id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", topic_id: post.topic_id])
["id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)",
topic_id: post.topic_id])
# inform user
if post.user

View file

@ -831,6 +831,7 @@ en:
flagging:
title: 'Why are you flagging this post?'
action: 'Flag Post'
take_action: "Take Action"
notify_action: 'Notify'
cant: "Sorry, you can't flag this post at this time."
custom_placeholder_notify_user: "Why does this post require you to speak to this user directly and privately? Be specific, be constructive, and always be kind."

View file

@ -0,0 +1,5 @@
class AddStaffTookActionToPostActions < ActiveRecord::Migration
def change
add_column :post_actions, :staff_took_action, :boolean, default: false, null: false
end
end

View file

@ -9,7 +9,7 @@ describe PostActionsController do
describe 'logged in' do
before do
@user = log_in
@user = log_in(:moderator)
@post = Fabricate(:post, user: Fabricate(:coding_horror))
end
@ -34,9 +34,26 @@ describe PostActionsController do
end
it 'allows us to create an post action on a post' do
PostAction.expects(:act).once.with(@user, @post, PostActionType.types[:like], nil)
PostAction.expects(:act).once.with(@user, @post, PostActionType.types[:like], {})
xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.types[:like]
end
it 'passes the message through' do
PostAction.expects(:act).once.with(@user, @post, PostActionType.types[:like], {message: 'action message goes here'})
xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.types[:like], message: 'action message goes here'
end
it 'passes take_action through' do
PostAction.expects(:act).once.with(@user, @post, PostActionType.types[:like], {take_action: true})
xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.types[:like], take_action: 'true'
end
it "doesn't pass take_action through if the user isn't staff" do
Guardian.any_instance.stubs(:is_staff?).returns(false)
PostAction.expects(:act).once.with(@user, @post, PostActionType.types[:like], {})
xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.types[:like], take_action: 'true'
end
end
end

View file

@ -21,7 +21,7 @@ describe PostAction do
it "notify moderators integration test" do
mod = moderator
action = PostAction.act(codinghorror, post, PostActionType.types[:notify_moderators], "this is my special long message");
action = PostAction.act(codinghorror, post, PostActionType.types[:notify_moderators], message: "this is my special long message");
posts = Post.joins(:topic)
.select('posts.id, topics.subtype, posts.topic_id')
@ -50,7 +50,7 @@ describe PostAction do
it "sends an email to all moderators if selected" do
post = build(:post, id: 1000)
PostCreator.any_instance.expects(:create).returns(post)
PostAction.act(build(:user), build(:post), PostActionType.types[:notify_moderators], "this is my special message");
PostAction.act(build(:user), build(:post), PostActionType.types[:notify_moderators], message: "this is my special message");
end
end
@ -63,7 +63,7 @@ describe PostAction do
it "sends an email to user if selected" do
PostCreator.any_instance.expects(:create).returns(build(:post))
PostAction.act(build(:user), post, PostActionType.types[:notify_user], "this is my special message");
PostAction.act(build(:user), post, PostActionType.types[:notify_user], message: "this is my special message");
end
end
end
@ -179,9 +179,9 @@ describe PostAction do
flag = PostAction.act(Fabricate(:evil_trout), post, PostActionType.types[:spam])
PostAction.flag_counts_for(post.id).should == [0, 1]
# If an admin flags the post, it is counted higher
# If staff takes action, it is ranked higher
admin = Fabricate(:admin)
PostAction.act(admin, post, PostActionType.types[:spam])
pa = PostAction.act(admin, post, PostActionType.types[:spam], take_action: true)
PostAction.flag_counts_for(post.id).should == [0, 8]
# If a flag is dismissed