FEATURE: Adds a pop up that shows a more detailed score for reviewables (#8035)

If you click a (?) icon beside the reviewable status a pop up will
appear with expanded informatio that explains how the reviewable got its
score, and how it compares to system thresholds.
This commit is contained in:
Robin Ward 2019-09-04 09:56:25 -06:00 committed by GitHub
parent e90636eadc
commit bde0ef865f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 287 additions and 2 deletions

View File

@ -0,0 +1,9 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
jsonMode: true,
pathFor(store, type, id) {
return `/review/${id}/explain.json`;
}
});

View File

@ -3,6 +3,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import computed from "ember-addons/ember-computed-decorators";
import Category from "discourse/models/category";
import optionalService from "discourse/lib/optional-service";
import showModal from "discourse/lib/show-modal";
let _components = {};
@ -140,6 +141,13 @@ export default Ember.Component.extend({
},
actions: {
explainReviewable(reviewable) {
showModal("explain-reviewable", {
title: "review.explain.title",
model: reviewable
});
},
edit() {
this.set("editing", true);
this._updates = { payload: {} };

View File

@ -0,0 +1,15 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
loading: null,
reviewableExplanation: null,
onShow() {
this.setProperties({ loading: true, reviewableExplanation: null });
this.store
.find("reviewable-explanation", this.model.id)
.then(result => this.set("reviewableExplanation", result))
.finally(() => this.set("loading", false));
}
});

View File

@ -0,0 +1,5 @@
import { registerUnbound } from "discourse-common/lib/helpers";
registerUnbound("float", function(n) {
return parseFloat(n).toFixed(1);
});

View File

@ -10,6 +10,9 @@
<span class='status'>
{{reviewable-status reviewable.status}}
</span>
<a {{action "explainReviewable" reviewable}} class='explain' title={{i18n "review.explain.why"}}>
{{d-icon "question-circle"}}
</a>
</div>
<div class='reviewable-contents'>

View File

@ -0,0 +1,11 @@
{{#if value}}
<span class='score-value'>
<span class='score-number'>{{float value}}</span>
{{#if label}}
<span class='score-value-type' title={{i18n (concat "review.explain." label ".title")}}>
{{i18n (concat "review.explain." label ".name")}}
</span>
{{/if}}
</span>
<span class='op'>+</span>
{{/if}}

View File

@ -0,0 +1,47 @@
{{#d-modal-body class="explain-reviewable"}}
{{#conditional-loading-spinner condition=loading}}
<table>
<tr>
<th>{{i18n "review.explain.formula"}}</th>
<th>{{i18n "review.explain.subtotal"}}</th>
</tr>
{{#each reviewableExplanation.scores as |s|}}
<tr>
<td>
{{score-value value="1.0" tagName=""}}
{{score-value value=s.type_bonus label="type_bonus" tagName=""}}
{{score-value value=s.take_action_bonus label="take_action_bonus" tagName=""}}
{{score-value value=s.trust_level_bonus label="trust_level_bonus" tagName=""}}
{{score-value value=s.user_accuracy_bonus label="user_accuracy_bonus" tagName=""}}
</td>
<td class='sum'>{{float s.score}}</td>
</tr>
{{/each}}
<tr class="total">
<td>{{i18n "review.explain.total"}}</td>
<td class='sum'>{{float reviewableExplanation.total_score}}</td>
</tr>
</table>
<table class='thresholds'>
<tr>
<td>{{i18n "review.explain.min_score_visibility"}}</td>
<td class='sum'>
{{float reviewableExplanation.min_score_visibility}}
</td>
</tr>
<tr>
<td>{{i18n "review.explain.score_to_hide"}}</td>
<td class='sum'>
{{float reviewableExplanation.hide_post_score}}
</td>
</tr>
</table>
{{/conditional-loading-spinner}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action=(route-action "closeModal") label="close"}}
</div>

View File

@ -0,0 +1,37 @@
.explain-reviewable {
min-width: 500px;
.thresholds {
margin-top: 1em;
}
table {
width: 100%;
}
table td {
padding: 0.5em;
}
td.sum {
text-align: right;
}
td.sum.total {
font-weight: bold;
}
tr.total {
td {
background-color: $primary-low;
font-weight: bold;
}
}
.op {
font-weight: bold;
}
.score-value-type {
color: $primary-medium;
}
.op:last-of-type {
display: none;
}
}

View File

@ -20,6 +20,9 @@
}
}
}
.explain {
margin-left: 0.5em;
}
.nav-pills {
margin-bottom: 1em;

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true
require_dependency 'reviewable_explanation_serializer'
class ReviewablesController < ApplicationController
requires_login
@ -102,6 +103,17 @@ class ReviewablesController < ApplicationController
)
end
def explain
reviewable = find_reviewable
render_serialized(
{ reviewable: reviewable, scores: reviewable.explain_score },
ReviewableExplanationSerializer,
rest_serializer: true,
root: 'reviewable_explanation'
)
end
def show
reviewable = find_reviewable

View File

@ -481,6 +481,25 @@ class Reviewable < ActiveRecord::Base
.count
end
def explain_score
DB.query(<<~SQL, reviewable_id: id)
SELECT rs.reviewable_id,
rs.user_id,
CASE WHEN (u.admin OR u.moderator) THEN 5.0 ELSE u.trust_level END AS trust_level_bonus,
us.flags_agreed,
us.flags_disagreed,
us.flags_ignored,
rs.score,
rs.take_action_bonus,
COALESCE(pat.score_bonus, 0.0) AS type_bonus
FROM reviewable_scores AS rs
INNER JOIN users AS u ON u.id = rs.user_id
LEFT OUTER JOIN user_stats AS us ON us.user_id = rs.user_id
LEFT OUTER JOIN post_action_types AS pat ON pat.id = rs.reviewable_score_type
WHERE rs.reviewable_id = :reviewable_id
SQL
end
protected
def recalculate_score

View File

@ -59,10 +59,22 @@ class ReviewableScore < ActiveRecord::Base
user_stat = user&.user_stat
return 0.0 if user_stat.blank?
total = (user_stat.flags_agreed + user_stat.flags_disagreed + user_stat.flags_ignored).to_f
calc_user_accuracy_bonus(
user_stat.flags_agreed,
user_stat.flags_disagreed,
user_stat.flags_ignored
)
end
def self.calc_user_accuracy_bonus(agreed, disagreed, ignored)
agreed ||= 0
disagreed ||= 0
ignored ||= 0
total = (agreed + disagreed + ignored).to_f
return 0.0 if total <= 5
(user_stat.flags_agreed / total) * 5.0
(agreed / total) * 5.0
end
def reviewable_conversation

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
require_dependency 'reviewable_score_explanation_serializer'
class ReviewableExplanationSerializer < ApplicationSerializer
attributes(
:id,
:total_score,
:scores,
:min_score_visibility,
:hide_post_score
)
has_many :scores, serializer: ReviewableScoreExplanationSerializer, embed: :objects
def id
object[:reviewable].id
end
def hide_post_score
Reviewable.score_required_to_hide_post
end
def spam_silence_score
Reviewable.spam_score_to_silence_new_user
end
def min_score_visibility
Reviewable.min_score_for_priority
end
def total_score
object[:reviewable].score
end
def scores
object[:scores]
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class ReviewableScoreExplanationSerializer < ApplicationSerializer
attributes(
:user_id,
:type_bonus,
:trust_level_bonus,
:take_action_bonus,
:flags_agreed,
:flags_disagreed,
:flags_ignored,
:user_accuracy_bonus,
:score
)
def user_accuracy_bonus
ReviewableScore.calc_user_accuracy_bonus(
object.flags_agreed,
object.flags_disagreed,
object.flags_ignored
)
end
end

View File

@ -370,6 +370,23 @@ en:
review:
order_by: "Order by"
in_reply_to: "in reply to"
explain:
why: "explain why this item ended up in the queue"
title: "Reviewable Scoring"
formula: "Formula"
subtotal: "Subtotal"
total: "Total"
min_score_visibility: "Minimum Score for Visibility"
score_to_hide: "Score to Hide Post"
user_accuracy_bonus:
name: "user accuracy"
title: "Users whose flags have been historically agreed with are given a bonus."
trust_level_bonus:
name: "trust level"
title: "Reviewable items created by higher trust level users have a higher score."
type_bonus:
name: "type bonus"
title: "Certain reviewable types can be assigned a bonus by staff to make them a higher priority."
claim_help:
optional: "You can claim this item to prevent others from reviewing it."
required: "You must claim items before you can review them."

View File

@ -324,6 +324,7 @@ Discourse::Application.routes.draw do
get "review" => "reviewables#index" # For ember app
get "review/:reviewable_id" => "reviewables#show", constraints: { reviewable_id: /\d+/ }
get "review/:reviewable_id/explain" => "reviewables#explain", constraints: { reviewable_id: /\d+/ }
get "review/topics" => "reviewables#topics"
get "review/settings" => "reviewables#settings"
put "review/settings" => "reviewables#settings"

View File

@ -236,6 +236,30 @@ describe ReviewablesController do
end
end
context "#explain" do
context "basics" do
fab!(:reviewable) { Fabricate(:reviewable) }
before do
sign_in(Fabricate(:moderator))
end
it "returns the explanation as json" do
get "/review/#{reviewable.id}/explain.json"
expect(response.code).to eq("200")
json = ::JSON.parse(response.body)
expect(json['reviewable_explanation']['id']).to eq(reviewable.id)
expect(json['reviewable_explanation']['total_score']).to eq(reviewable.score)
end
it "returns 404 for a missing reviewable" do
get "/review/123456789/explain.json"
expect(response.code).to eq("404")
end
end
end
context "#perform" do
fab!(:reviewable) { Fabricate(:reviewable) }
before do