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, { 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) { 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); this.set('selected', action);
return false;
}, },
showSubmit: function() { submitEnabled: function() {
if (this.get('postActionTypeId')) { var selected = this.get('selected');
if (this.get('isCustomFlag')) { if (!selected) return false;
var m = this.get('selected.message');
return m && m.length >= 10 && m.length <= 500; if (selected.get('is_custom_flag')) {
} else { var len = this.get('message.length') || 0;
return true; return len >= Discourse.PostActionType.MIN_MESSAGE_LENGTH &&
} len <= Discourse.PostActionType.MAX_MESSAGE_LENGTH;
} }
return false; return true;
}.property('isCustomFlag', 'selected.customMessageLength', 'postActionTypeId'), }.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(){ submitText: function(){
var action = this.get('selected');
if (this.get('selected.is_custom_flag')) { if (this.get('selected.is_custom_flag')) {
return Em.String.i18n("flagging.notify_action"); return Em.String.i18n("flagging.notify_action");
} else { } else {
return Em.String.i18n("flagging.action"); return Em.String.i18n("flagging.action");
} }
}.property('selected'), }.property('selected.is_custom_flag'),
createFlag: function() { takeAction: function() {
var _this = this; this.createFlag({takeAction: true})
this.set('hidden', true);
},
var action = this.get('selected'); createFlag: function(opts) {
var postAction = this.get('actionByName.' + (action.get('name_key'))); 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 (opts) params = $.extend(params, opts);
if (postAction) {
postAction.act({ postAction.act(params).then(function() {
message: action.get('message') flagController.closeModal();
}).then(function() { }, function(errors) {
return $('#discourse-modal').modal('hide'); flagController.displayErrors(errors);
}, function(errors) { });
return _this.displayErrors(errors);
});
}
return false;
} }
}); });

View file

@ -8,6 +8,16 @@
**/ **/
Discourse.ModalController = Discourse.Controller.extend({ 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, message: message,
messageClass: messageClass 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 // Perform this action
act: function(opts) { act: function(opts) {
if (!opts) opts = {};
var action = this.get('actionType.name_key'); var action = this.get('actionType.name_key');
// Mark it as acted // Mark it as acted
@ -63,7 +65,8 @@ Discourse.ActionSummary = Discourse.Model.extend({
data: { data: {
id: this.get('post.id'), id: this.get('post.id'),
post_action_type_id: this.get('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) { }).then(null, function (error) {
actionSummary.removeAction(); actionSummary.removeAction();

View file

@ -6,31 +6,9 @@
@namespace Discourse @namespace Discourse
@module Discourse @module Discourse
**/ **/
Discourse.PostActionType = Discourse.Model.extend({ Discourse.PostActionType = Discourse.Model.extend({});
});
Discourse.BoundPostActionType = Discourse.PostActionType.extend({ Discourse.PostActionType.reopenClass({
customPlaceholder: function(){ MIN_MESSAGE_LENGTH: 10,
return Em.String.i18n("flagging.custom_placeholder_" + this.get('name_key')); MAX_MESSAGE_LENGTH: 500
}.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")
});

View file

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

View file

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

View file

@ -1,36 +1,30 @@
<div class="modal-body flag-modal"> <div class="modal-body flag-modal">
{{#if flagsAvailable}}
<form> <form>
{{#each boundFlags}} {{#each flagsAvailable itemController="flagActionType"}}
<div class='controls'> <div class='controls'>
<label class='radio'> <label class='radio'>
<input type='radio' id="radio_{{unbound name_key}}" {{action changePostActionType this}} name='post_action_type_index'> <strong>{{formattedName}}</strong> <input type='radio' id="radio_{{unbound name_key}}" {{action changePostActionType this}} name='post_action_type_index'> <strong>{{formattedName}}</strong>
{{#if is_custom_flag}} {{#if showDescription}}
{{#unless selected}} <div class='description'>{{{description}}}</div>
<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}}
{{/if}} {{/if}}
</div> </label>
{{/each}} {{#if showMessageInput}}
</form> {{textarea name="message" class="flag-message" placeholder=customPlaceholder value=message}}
{{else}} <div {{bindAttr class=":custom-message-length customMessageLengthClasses"}}>{{customMessageLength}}</div>
{{i18n flagging.cant}} {{/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}} {{/if}}
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
{{#if finished}} {{#if finished}}
<button class='btn btn-primary' data-dismiss="modal">{{i18n close}}</button> <button class='btn btn-primary' {{action closeModal}}>{{i18n close}}</button>
{{else}} {{else}}
<button class='btn btn-primary' {{bindAttr disabled="disabled"}} {{action createInvite}}>{{buttonTitle}}</button> <button class='btn btn-primary' {{bindAttr disabled="disabled"}} {{action createInvite}}>{{buttonTitle}}</button>
{{/if}} {{/if}}

View file

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

View file

@ -1,5 +1,5 @@
<div class="modal-header"> <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> <h3>{{title}}</h3>
</div> </div>
<div id='modal-alert'></div> <div id='modal-alert'></div>

View file

@ -7,5 +7,5 @@
{{/if}} {{/if}}
</div> </div>
<div class="modal-footer"> <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> </div>

View file

@ -9,7 +9,11 @@ class PostActionsController < ApplicationController
def create def create
guardian.ensure_post_can_act!(@post, PostActionType.types[@post_action_type_id]) 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? if post_action.blank? || post_action.errors.present?
render_json_error(post_action) render_json_error(post_action)

View file

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

View file

@ -831,6 +831,7 @@ en:
flagging: flagging:
title: 'Why are you flagging this post?' title: 'Why are you flagging this post?'
action: 'Flag Post' action: 'Flag Post'
take_action: "Take Action"
notify_action: 'Notify' notify_action: 'Notify'
cant: "Sorry, you can't flag this post at this time." 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." 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 describe 'logged in' do
before do before do
@user = log_in @user = log_in(:moderator)
@post = Fabricate(:post, user: Fabricate(:coding_horror)) @post = Fabricate(:post, user: Fabricate(:coding_horror))
end end
@ -34,9 +34,26 @@ describe PostActionsController do
end end
it 'allows us to create an post action on a post' do 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] xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.types[:like]
end 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
end end

View file

@ -21,7 +21,7 @@ describe PostAction do
it "notify moderators integration test" do it "notify moderators integration test" do
mod = moderator 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) posts = Post.joins(:topic)
.select('posts.id, topics.subtype, posts.topic_id') .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 it "sends an email to all moderators if selected" do
post = build(:post, id: 1000) post = build(:post, id: 1000)
PostCreator.any_instance.expects(:create).returns(post) 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
end end
@ -63,7 +63,7 @@ describe PostAction do
it "sends an email to user if selected" do it "sends an email to user if selected" do
PostCreator.any_instance.expects(:create).returns(build(:post)) 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 end
end end
@ -179,9 +179,9 @@ describe PostAction do
flag = PostAction.act(Fabricate(:evil_trout), post, PostActionType.types[:spam]) flag = PostAction.act(Fabricate(:evil_trout), post, PostActionType.types[:spam])
PostAction.flag_counts_for(post.id).should == [0, 1] 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) 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] PostAction.flag_counts_for(post.id).should == [0, 8]
# If a flag is dismissed # If a flag is dismissed