FEATURE: Allow users to customize bonuses for reviewable types

A new settings section in the review queue allows admins to specify that
certain types of flags should be weighted higher than others.
This commit is contained in:
Robin Ward 2019-04-02 17:00:15 -04:00
parent da2f659635
commit 62956003c3
20 changed files with 333 additions and 132 deletions

View File

@ -0,0 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
pathFor() {
return "/review/settings";
}
});

View File

@ -1,4 +1,5 @@
/* You might be looking for navigation-item. */
import { iconHTML } from "discourse-common/lib/icon-library";
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
@ -6,9 +7,13 @@ export default Ember.Component.extend({
classNameBindings: ["active"],
router: Ember.inject.service(),
@computed("path")
fullPath(path) {
return Discourse.getURL(path);
@computed("label", "i18nLabel", "icon")
contents(label, i18nLabel, icon) {
let text = i18nLabel || I18n.t(label);
if (icon) {
return `${iconHTML(icon)} ${text}`.htmlSafe();
}
return text;
},
@computed("route", "router.currentRoute")

View File

@ -0,0 +1,24 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Ember.Controller.extend({
saving: false,
saved: false,
actions: {
save() {
let bonuses = {};
this.get("settings.reviewable_score_types").forEach(st => {
bonuses[st.id] = parseFloat(st.score_bonus);
});
this.set("saving", true);
ajax("/review/settings", { method: "PUT", data: { bonuses } })
.then(() => {
this.set("saved", true);
})
.catch(popupAjaxError)
.finally(() => this.set("saving", false));
}
}
});

View File

@ -173,6 +173,7 @@ export default function() {
this.route("show", { path: "/:reviewable_id" });
this.route("index", { path: "/" });
this.route("topics", { path: "/topics" });
this.route("settings", { path: "/settings" });
});
this.route("signup", { path: "/signup" });
this.route("login", { path: "/login" });

View File

@ -0,0 +1,9 @@
export default Discourse.Route.extend({
model() {
return this.store.find("reviewable-settings");
},
setupController(controller, model) {
controller.set("settings", model);
}
});

View File

@ -1,17 +1,7 @@
{{#if routeParam}}
{{#if i18nLabel}}
{{#link-to route routeParam}}{{i18nLabel}}{{/link-to}}
{{else}}
{{#link-to route routeParam}}{{i18n label}}{{/link-to}}
{{/if}}
{{#link-to route routeParam}}{{contents}}{{/link-to}}
{{else if route}}
{{#link-to route}}{{contents}}{{/link-to}}
{{else}}
{{#if route}}
{{#link-to route}}{{i18n label}}{{/link-to}}
{{else}}
{{#if path}}
<a href="{{unbound fullPath}}" data-auto-route="true">{{i18n label}}</a>
{{else}}
<a href="{{unbound href}}" data-auto-route="true">{{i18n label}}</a>
{{/if}}
{{/if}}
<a href="{{get-url path}}" data-auto-route="true">{{contents}}</a>
{{/if}}

View File

@ -1,83 +1,76 @@
<div class="reviewable">
<ul class="nav nav-pills reviewable-title">
{{nav-item route='review.index' label='review.view_all'}}
{{nav-item route='review.topics' label='review.grouped_by_topic'}}
</ul>
<div class="reviewable-container">
<div class="reviewable-list">
{{#if reviewables}}
{{#load-more selector=".reviewable-item" action=(action "loadMore")}}
<div class='reviewables'>
{{#each reviewables as |r|}}
{{reviewable-item reviewable=r remove=(action "remove")}}
{{/each}}
</div>
{{/load-more}}
{{conditional-loading-spinner condition=reviewables.loadingMore}}
{{else}}
<div class="no-review">
{{i18n "review.none"}}
<div class="reviewable-container">
<div class="reviewable-list">
{{#if reviewables}}
{{#load-more selector=".reviewable-item" action=(action "loadMore")}}
<div class='reviewables'>
{{#each reviewables as |r|}}
{{reviewable-item reviewable=r remove=(action "remove")}}
{{/each}}
</div>
{{/if}}
{{/load-more}}
{{conditional-loading-spinner condition=reviewables.loadingMore}}
{{else}}
<div class="no-review">
{{i18n "review.none"}}
</div>
{{/if}}
</div>
<div class='reviewable-filters'>
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.status"}}</label>
{{combo-box value=filterStatus content=statuses}}
</div>
<div class='reviewable-filters'>
{{#if filtersExpanded}}
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.status"}}</label>
{{combo-box value=filterStatus content=statuses}}
<label class='filter-label'>{{i18n "review.filters.type.title"}}</label>
{{combo-box value=filterType content=allTypes none="review.filters.type.all"}}
</div>
{{#if filtersExpanded}}
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.type.title"}}</label>
{{combo-box value=filterType content=allTypes none="review.filters.type.all"}}
</div>
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.minimum_score"}}</label>
{{input value=filterScore class="score-filter"}}
</div>
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.minimum_score"}}</label>
{{input value=filterScore class="score-filter"}}
</div>
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.category"}}</label>
{{category-chooser none="category.all" value=filterCategoryId}}
</div>
<div class='reviewable-filter'>
<label class='filter-label'>{{i18n "review.filters.category"}}</label>
{{category-chooser none="category.all" value=filterCategoryId}}
</div>
<div class='reviewable-filter topic-filter'>
{{i18n "review.filtered_user"}}
{{user-selector
excludeCurrentUser=false
usernames=filterUsername
fullWidthWrap="true"
class="user-selector"
single="true"
canReceiveUpdates="true"}}
</div>
{{#if filterTopic}}
<div class='reviewable-filter topic-filter'>
{{i18n "review.filtered_user"}}
{{user-selector
excludeCurrentUser=false
usernames=filterUsername
fullWidthWrap="true"
class="user-selector"
single="true"
canReceiveUpdates="true"}}
{{i18n "review.filtered_topic"}}
{{d-button label="review.show_all_topics" icon="times" action=(action "resetTopic")}}
</div>
{{#if filterTopic}}
<div class='reviewable-filter topic-filter'>
{{i18n "review.filtered_topic"}}
{{d-button label="review.show_all_topics" icon="times" action=(action "resetTopic")}}
</div>
{{/if}}
{{/if}}
{{/if}}
<div class='reviewable-filters-actions'>
<div class='reviewable-filters-actions'>
{{d-button
icon="sync"
label="review.filters.refresh"
class="btn-primary refresh" action=(action "refresh")}}
{{#if site.mobileView}}
{{d-button
icon="sync"
label="review.filters.refresh"
class="btn-primary refresh" action=(action "refresh")}}
{{#if site.mobileView}}
{{d-button
label="show_help"
icon=toggleFiltersIcon
class="btn-default expand-secondary-filters"
action=(action "toggleFilters")}}
{{/if}}
</div>
label="show_help"
icon=toggleFiltersIcon
class="btn-default expand-secondary-filters"
action=(action "toggleFilters")}}
{{/if}}
</div>
</div>
</div>

View File

@ -0,0 +1,29 @@
<div class='reviewable-settings'>
<h4>{{i18n "review.settings.score_bonuses.title"}}</h4>
<p class='description'>{{i18n "review.settings.score_bonuses.description"}}</p>
{{#each settings.reviewable_score_types as |rst|}}
<div class='reviewable-score-type'>
<div class='title'>{{rst.title}}</div>
<div class='field'>
{{input value=rst.score_bonus}}
</div>
</div>
{{/each}}
<div class='reviewable-score-type'>
<div class='title'></div>
<div class='field'>
{{d-button
icon="check"
label="review.settings.save_changes"
class="btn-primary save-settings"
action=(action "save")
disabled=saving}}
{{#if saved}}
<span class='saved'>{{i18n "review.settings.saved"}}</span>
{{/if}}
</div>
</div>
</div>

View File

@ -1,45 +1,38 @@
<div class="reviewable">
<ul class="nav nav-pills reviewable-title">
{{nav-item route='review.index' routeParam=(query-params topic_id=null) label='review.view_all'}}
{{nav-item route='review.topics' label='review.grouped_by_topic'}}
</ul>
{{#if reviewableTopics}}
<table class='reviewable-topics'>
<thead>
<th>{{i18n "review.topics.topic"}} </th>
<th>{{i18n "review.topics.reviewable_count"}}</th>
<th>{{i18n "review.topics.reported_by"}}</th>
<th></th>
</thead>
<tbody>
{{#each reviewableTopics as |rt|}}
<tr class='reviewable-topic'>
<td class="topic-title">
<div class='combined-title'>
{{topic-status topic=rt}}
<a href={{rt.relative_url}} target="_blank">{{replace-emoji rt.fancy_title}}</a>
</div>
</td>
<td class="reviewable-count">
{{rt.stats.count}}
</td>
<td class="reported-by">
{{i18n "review.topics.unique_users" count=rt.stats.unique_users}}
</td>
<td class="reviewable-details">
{{#link-to "review.index" (query-params topic_id=rt.id) class="btn btn-primary btn-small"}}
{{d-icon "list"}}
<span>{{i18n "review.topics.details"}}</span>
{{/link-to}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<div class="no-review">
{{i18n "review.none"}}
</div>
{{/if}}
</div>
{{#if reviewableTopics}}
<table class='reviewable-topics'>
<thead>
<th>{{i18n "review.topics.topic"}} </th>
<th>{{i18n "review.topics.reviewable_count"}}</th>
<th>{{i18n "review.topics.reported_by"}}</th>
<th></th>
</thead>
<tbody>
{{#each reviewableTopics as |rt|}}
<tr class='reviewable-topic'>
<td class="topic-title">
<div class='combined-title'>
{{topic-status topic=rt}}
<a href={{rt.relative_url}} target="_blank">{{replace-emoji rt.fancy_title}}</a>
</div>
</td>
<td class="reviewable-count">
{{rt.stats.count}}
</td>
<td class="reported-by">
{{i18n "review.topics.unique_users" count=rt.stats.unique_users}}
</td>
<td class="reviewable-details">
{{#link-to "review.index" (query-params topic_id=rt.id) class="btn btn-primary btn-small"}}
{{d-icon "list"}}
<span>{{i18n "review.topics.details"}}</span>
{{/link-to}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<div class="no-review">
{{i18n "review.none"}}
</div>
{{/if}}

View File

@ -1 +1,11 @@
{{outlet}}
<div class="reviewable">
<ul class="nav nav-pills reviewable-title">
{{nav-item route='review.index' label='review.view_all'}}
{{nav-item route='review.topics' label='review.grouped_by_topic'}}
{{#if currentUser.admin}}
{{nav-item route='review.settings' label='review.settings.title' icon='wrench'}}
{{/if}}
</ul>
{{outlet}}
</div>

View File

@ -1,4 +1,8 @@
.reviewable {
.nav-pills {
margin-bottom: 1em;
}
.reviewable-container {
display: flex;
flex-direction: row;
@ -23,6 +27,23 @@
}
}
.reviewable-settings {
p.description {
margin-bottom: 2em;
}
.saved {
margin-left: 0.5em;
}
.reviewable-score-type {
display: flex;
.title {
width: 30%;
}
}
}
.reviewable-user-details {
margin: 0.5em 0;
}

View File

@ -143,6 +143,21 @@ class ReviewablesController < ApplicationController
end
end
def settings
raise Discourse::InvalidAccess.new unless current_user.admin?
post_action_types = PostActionType.where(id: PostActionType.flag_types.values).order('id')
data = { reviewable_score_types: post_action_types }
if request.put?
params[:bonuses].each do |id, bonus|
PostActionType.where(id: id).update_all(score_bonus: bonus.to_f)
end
end
render_serialized(data, ReviewableSettingsSerializer, rest_serializer: true)
end
protected
def find_reviewable

View File

@ -0,0 +1,7 @@
class ReviewableScoreBonusSerializer < ApplicationSerializer
attributes :id, :name, :score_bonus
def name
I18n.t("post_action_types.#{object.name_key}.title")
end
end

View File

@ -1,5 +1,5 @@
class ReviewableScoreTypeSerializer < ApplicationSerializer
attributes :id, :title
attributes :id, :title, :score_bonus
# Allow us to share post action type translations for backwards compatibility
def title
@ -7,4 +7,12 @@ class ReviewableScoreTypeSerializer < ApplicationSerializer
I18n.t("reviewable_score_types.#{ReviewableScore.types[id]}.title")
end
def score_bonus
object.score_bonus.to_f
end
def include_score_bonus?
object.respond_to?(:score_bonus)
end
end

View File

@ -0,0 +1,13 @@
class ReviewableSettingsSerializer < ApplicationSerializer
attributes :id
has_many :reviewable_score_types, serializer: ReviewableScoreTypeSerializer
def id
scope.user.id
end
def reviewable_score_types
object[:reviewable_score_types]
end
end

View File

@ -359,9 +359,17 @@ en:
placeholder: "type the message title here"
review:
settings:
saved: "Saved"
save_changes: "Save Changes"
title: "Settings"
score_bonuses:
title: "Score Bonuses"
description: "Bonuses allow certain types to be scored higher than others so they can be prioritized. Note: changing these values will not apply to previously scored items."
moderation_history: "Moderation History"
view_all: "view all"
grouped_by_topic: "grouped by topic"
view_all: "View All"
grouped_by_topic: "Grouped by Topic"
none: "There are no items to review."
view_pending: "view pending"
topic_has_pending:

View File

@ -318,6 +318,8 @@ Discourse::Application.routes.draw do
get "review" => "reviewables#index" # For ember app
get "review/:reviewable_id" => "reviewables#show", constraints: { reviewable_id: /\d+/ }
get "review/topics" => "reviewables#topics"
get "review/settings" => "reviewables#settings"
put "review/settings" => "reviewables#settings"
put "review/:reviewable_id/perform/:action_id" => "reviewables#perform", constraints: {
reviewable_id: /\d+/,
action_id: /[a-z\_]+/

View File

@ -12,6 +12,22 @@ describe ReviewablesController do
put "/review/123/perform/approve.json"
expect(response.code).to eq("403")
end
it "denies settings" do
get "/review/settings.json"
expect(response.code).to eq("403")
end
end
context "regular user" do
before do
sign_in(Fabricate(:user))
end
it "does not allow settings" do
get "/review/settings.json"
expect(response.code).to eq("403")
end
end
context "when logged in" do
@ -307,6 +323,22 @@ describe ReviewablesController do
end
end
context "#settings" do
it "renders the settings as JSON" do
get "/review/settings.json"
expect(response.code).to eq("200")
json = ::JSON.parse(response.body)
expect(json['reviewable_settings']).to be_present
expect(json['reviewable_score_types']).to be_present
end
it "allows the settings to be updated" do
put "/review/settings.json", params: { bonuses: { 8 => 3.45 } }
expect(response.code).to eq("200")
expect(PostActionType.find_by(id: 8).score_bonus).to eq(3.45)
end
end
context "#update" do
let(:reviewable) { Fabricate(:reviewable) }
let(:reviewable_post) { Fabricate(:reviewable_queued_post) }

View File

@ -33,6 +33,16 @@ QUnit.test("Grouped by topic", async assert => {
);
});
QUnit.test("Settings", async assert => {
await visit("/review/settings");
assert.ok(find(".reviewable-score-type").length, "has a list of bonuses");
await fillIn(".reviewable-score-type:eq(0) .field input ", "0.5");
await click(".save-settings");
assert.ok(find(".reviewable-settings .saved").length, "it saved");
});
QUnit.test("Flag related", async assert => {
await visit("/review");

View File

@ -89,6 +89,30 @@ export default function(helpers) {
});
});
this.get("/review/settings", () => {
return response(200, {
reviewable_score_types: [
{
id: 3,
title: "Off-Topic",
score_bonus: 0.0
},
{
id: 4,
title: "Inappropriate",
score_bonus: 0.0
}
],
reviewable_settings: {
id: 13870,
reviewable_score_type_ids: [3, 4]
},
__rest_serializer: "1"
});
});
this.put("/review/settings", () => response(200, {}));
this.get("/review/:id", () => {
return response(200, {
reviewable: flag