FEATURE: Add Revise... option for queued post reviewable (#23454)

This commit adds a new Revise... action that can be taken
for queued post reviewables. This will open a modal where
the user can select a Reason from a preconfigured list
(or by choosing Other..., a custom reason) and provide feedback
to the user about their post.

The post will be rejected still, but a PM will also be sent to
the user so they have an opportunity to improve their post when
they resubmit it.
This commit is contained in:
Martin Brennan 2023-10-13 11:28:31 +10:00 committed by GitHub
parent 5fe4e0ed48
commit 9762e65758
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 385 additions and 7 deletions

View File

@ -0,0 +1,67 @@
<DModal
class="revise-and-reject-reviewable"
@closeModal={{@closeModal}}
@title={{i18n "review.revise_and_reject_post.title"}}
>
<:body>
<div class="revise-and-reject-reviewable__queued-post">
<ReviewableQueuedPost @reviewable={{@model.reviewable}} @tagName="" />
</div>
<div class="control-group">
<label class="control-label" for="reason">{{i18n
"review.revise_and_reject_post.reason"
}}</label>
<ComboBox
@name="reason"
@content={{this.configuredReasons}}
@value={{this.reason}}
@onChange={{action (mut this.reason)}}
@class="revise-and-reject-reviewable__reason"
/>
</div>
{{#if this.showCustomReason}}
<div class="control-group">
<label class="control-label" for="custom_reason">{{i18n
"review.revise_and_reject_post.custom_reason"
}}</label>
<Input
name="custom_reason"
class="revise-and-reject-reviewable__custom-reason"
@type="text"
@value={{this.customReason}}
/>
</div>
{{/if}}
<div class="control-group">
<label class="control-label" for="feedback">{{i18n
"review.revise_and_reject_post.feedback"
}}
<span class="revise-and-reject-reviewable__optional">({{i18n
"review.revise_and_reject_post.optional"
}})</span>
</label>
<DTextarea
@name="feedback"
@value={{this.feedback}}
@onChange={{action (mut this.feedback)}}
@class="revise-and-reject-reviewable__feedback"
/>
</div>
</:body>
<:footer>
<DButton
class="btn-primary"
@action={{this.rejectAndSendPM}}
@disabled={{this.sendPMDisabled}}
@label="review.revise_and_reject_post.send_pm"
/>
<DButton
class="btn-flat d-modal-cancel"
@action={{@closeModal}}
@label="cancel"
/>
</:footer>
</DModal>

View File

@ -0,0 +1,62 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
const OTHER_REASON = "other_reason";
export default class ReviseAndRejectPostReviewable extends Component {
@service siteSettings;
@tracked reason;
@tracked customReason;
@tracked feedback;
@tracked submitting = false;
get configuredReasons() {
const reasons = this.siteSettings.reviewable_revision_reasons
.split("|")
.filter(Boolean)
.map((reason) => ({ id: reason, name: reason }))
.concat([
{
id: OTHER_REASON,
name: I18n.t("review.revise_and_reject_post.other_reason"),
},
]);
return reasons;
}
get showCustomReason() {
return this.reason === OTHER_REASON;
}
get sendPMDisabled() {
return (
isEmpty(this.reason) ||
(this.reason === OTHER_REASON && isEmpty(this.customReason)) ||
this.submitting
);
}
@action
async rejectAndSendPM() {
this.submitting = true;
try {
await this.args.model.performConfirmed(this.args.model.action, {
revise_reason: this.reason,
revise_custom_reason: this.customReason,
revise_feedback: this.feedback,
});
this.args.closeModal();
} catch (error) {
popupAjaxError(error);
} finally {
this.submitting = false;
}
}
}

View File

@ -4,6 +4,7 @@ import { action, set } from "@ember/object";
import { inject as service } from "@ember/service";
import { classify, dasherize } from "@ember/string";
import ExplainReviewableModal from "discourse/components/modal/explain-reviewable";
import ReviseAndRejectPostReviewable from "discourse/components/modal/revise-and-reject-post-reviewable";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import optionalService from "discourse/lib/optional-service";
@ -15,7 +16,13 @@ import I18n from "I18n";
let _components = {};
const pluginReviewableParams = {};
const actionModalClassMap = {};
// The mappings defined here are default core mappings, and cannot be overridden
// by plugins.
const defaultActionModalClassMap = {
revise_and_reject_post: ReviseAndRejectPostReviewable,
};
const actionModalClassMap = { ...defaultActionModalClassMap };
export function addPluginReviewableParam(reviewableType, param) {
pluginReviewableParams[reviewableType]
@ -24,6 +31,11 @@ export function addPluginReviewableParam(reviewableType, param) {
}
export function registerReviewableActionModal(actionName, modalClass) {
if (Object.keys(defaultActionModalClassMap).includes(actionName)) {
throw new Error(
`Cannot override default action modal class for ${actionName} (mapped to ${defaultActionModalClassMap[actionName].name})!`
);
}
actionModalClassMap[actionName] = modalClass;
}
@ -135,7 +147,7 @@ export default Component.extend({
},
@bind
_performConfirmed(performableAction) {
_performConfirmed(performableAction, additionalData = {}) {
let reviewable = this.reviewable;
let performAction = () => {
@ -145,6 +157,7 @@ export default Component.extend({
const data = {
send_email: reviewable.sendEmail,
reject_reason: reviewable.rejectReason,
...additionalData,
};
(pluginReviewableParams[reviewable.type] || []).forEach((param) => {

View File

@ -41,6 +41,7 @@
@import "request_access";
@import "request-group-membership-form";
@import "reviewables";
@import "revise-and-reject-post-reviewable";
@import "rtl";
@import "search-menu";
@import "search";

View File

@ -0,0 +1,46 @@
.modal.revise-and-reject-reviewable {
.modal-inner-container {
max-width: 30em;
}
.modal-body {
.control-label {
font-weight: 700;
}
.select-kit {
width: 100%;
summary {
height: 100%;
}
}
}
.revise-and-reject-reviewable__optional {
margin-left: 0.5em;
color: var(--primary-low-mid);
}
.revise-and-reject-reviewable__custom-reason {
width: 100%;
}
.revise-and-reject-reviewable__queued-post {
@extend .reviewable-item;
padding: 1em;
margin: 0 0 1em 0;
.post-topic .title-text {
font-size: var(--font-up-1);
}
.post-body {
margin: 0;
p {
margin: 0;
}
}
}
}

View File

@ -222,11 +222,8 @@ class ReviewablesController < ApplicationController
return render_json_error(error)
end
if reviewable.type == "ReviewableUser"
args.merge!(
reject_reason: params[:reject_reason],
send_email: params[:send_email] != "false",
)
if reviewable.type_class.respond_to?(:additional_args)
args.merge!(reviewable.type_class.additional_args(params) || {})
end
plugin_params =

View File

@ -547,6 +547,10 @@ class Reviewable < ActiveRecord::Base
TYPE_TO_BASIC_SERIALIZER[self.type.to_sym] || BasicReviewableSerializer
end
def type_class
Reviewable.sti_class_for(self.type)
end
def self.lookup_serializer_for(type)
"#{type}Serializer".constantize
rescue NameError

View File

@ -16,6 +16,16 @@ class ReviewableQueuedPost < Reviewable
after_commit :compute_user_stats, only: %i[create update]
def self.additional_args(params)
return {} if params[:revise_reason].blank?
{
revise_reason: params[:revise_reason],
revise_feedback: params[:revise_feedback],
revise_custom_reason: params[:revise_custom_reason],
}
end
def updatable_reviewable_scores
# Approvals are possible for already rejected queued posts. We need the
# scores to be updated when this happens.
@ -57,6 +67,10 @@ class ReviewableQueuedPost < Reviewable
a.label = "reviewables.actions.reject_post.title"
end
end
actions.add(:revise_and_reject_post) do |a|
a.label = "reviewables.actions.revise_and_reject_post.title"
end
end
actions.add(:delete) if guardian.can_delete?(self)
@ -147,6 +161,24 @@ class ReviewableQueuedPost < Reviewable
create_result(:success, :rejected)
end
def perform_revise_and_reject_post(performed_by, args)
pm_translation_args = {
topic_title: self.topic.title,
topic_url: self.topic.url,
reason: args[:revise_custom_reason].presence || args[:revise_reason],
feedback: args[:revise_feedback],
original_post: self.payload["raw"],
site_name: SiteSetting.title,
}
SystemMessage.create_from_system_user(
self.target_created_by,
:reviewable_queued_post_revise_and_reject,
pm_translation_args,
)
StaffActionLogger.new(performed_by).log_post_rejected(self, DateTime.now) if performed_by.staff?
create_result(:success, :rejected)
end
def perform_delete(performed_by, args)
create_result(:success, :deleted)
end

View File

@ -5,6 +5,10 @@ class ReviewableUser < Reviewable
create(created_by_id: Discourse.system_user.id, target: user)
end
def self.additional_args(params)
{ reject_reason: params[:reject_reason], send_email: params[:send_email] != "false" }
end
def build_actions(actions, guardian, args)
return unless pending?

View File

@ -484,6 +484,14 @@ en:
type_bonus:
name: "type bonus"
title: "Certain reviewable types can be assigned a bonus by staff to make them a higher priority."
revise_and_reject_post:
title: "Revise"
reason: "Reason"
send_pm: "Send PM"
feedback: "Feedback"
custom_reason: "Give a clear description of the reason"
other_reason: "Other..."
optional: "optional"
stale_help: "This reviewable has been resolved by <b>%{username}</b>."
claim_help:
optional: "You can claim this item to prevent others from reviewing it."

View File

@ -2302,6 +2302,7 @@ en:
approve_new_topics_unless_trust_level: "New topics for users below this trust level must be approved"
approve_unless_staged: "New topics and posts for staged users must be approved"
notify_about_queued_posts_after: "If there are posts that have been waiting to be reviewed for more than this many hours, send a notification to all moderators. Set to 0 to disable these notifications."
reviewable_revision_reasons: "List of reasons that can be selected when rejecting a reviewable queued post with a revision. Other is always available as well, which allows for a custom reason to be entered."
auto_close_messages_post_count: "Maximum number of posts allowed in a message before it is automatically closed (0 to disable)"
auto_close_topics_post_count: "Maximum number of posts allowed in a topic before it is automatically closed (0 to disable)"
auto_close_topics_create_linked_topic: "Create a new linked topic when a topic is auto-closed based on 'auto close topics post count' setting"
@ -2985,6 +2986,29 @@ en:
For additional guidance, please refer to our [community guidelines](%{base_url}/guidelines).
reviewable_queued_post_revise_and_reject:
title: "Feedback on your post"
subject_template: "Feedback on your post in %{topic_title}"
text_body_template: |
Hi %{username},
We've reviewed your post in [%{topic_title}](%{topic_url}) and have some feedback for you.
Reason: %{reason}
Feedback: %{feedback}
You can edit your original post below and re-submit to make the suggested changes, or reply to this message if you have any questions.
--------
%{original_post}
--------
Thanks,
%{site_name} Moderators
post_hidden_again:
title: "Post Hidden again"
subject_template: "Post hidden by community flags, staff notified"
@ -5227,6 +5251,8 @@ en:
title: "No"
discard_post:
title: "Discard Post"
revise_and_reject_post:
title: "Revise Post..."
ignore:
title: "Ignore"
ignore_and_do_nothing:

View File

@ -1968,6 +1968,10 @@ spam:
reviewable_low_priority_threshold:
default: 0
min: 0
reviewable_revision_reasons:
default: "Duplicate|Does not meet posting guidelines"
type: list
client: true
rate_limits:
unique_posts_mins: 5

View File

@ -125,6 +125,61 @@ RSpec.describe ReviewableQueuedPost, type: :model do
end
end
context "with revise_and_reject_post" do
it "doesn't create the post the user intended" do
post_count = Post.public_posts.count
result = reviewable.perform(moderator, :revise_and_reject_post)
expect(result.success?).to eq(true)
expect(result.created_post).to be_nil
expect(Post.public_posts.count).to eq(post_count)
end
it "creates a private message to the creator of the post" do
args = { revise_reason: "Duplicate", revise_feedback: "This is old news" }
expect { reviewable.perform(moderator, :revise_and_reject_post, args) }.to change {
Topic.where(archetype: Archetype.private_message).count
}
topic = Topic.where(archetype: Archetype.private_message).last
expect(topic.title).to eq(
I18n.t(
"system_messages.reviewable_queued_post_revise_and_reject.subject_template",
topic_title: reviewable.topic.title,
),
)
translation_params = {
username: reviewable.target_created_by.username,
topic_title: reviewable.topic.title,
topic_url: reviewable.topic.url,
reason: args[:revise_reason],
feedback: args[:revise_feedback],
original_post: reviewable.payload["raw"],
site_name: SiteSetting.title,
}
expect(topic.first_post.raw.chomp).to eq(
I18n.t(
"system_messages.reviewable_queued_post_revise_and_reject.text_body_template",
translation_params,
).chomp,
)
end
it "supports sending a custom revise reason" do
args = {
revise_reason: "Other...",
revise_feedback: "This is old news",
revise_custom_reason: "Boring",
}
expect { reviewable.perform(moderator, :revise_and_reject_post, args) }.to change {
Topic.where(archetype: Archetype.private_message).count
}
topic = Topic.where(archetype: Archetype.private_message).last
expect(topic.first_post.raw).not_to include("Other...")
expect(topic.first_post.raw).to include("Boring")
end
end
context "with delete_user" do
it "deletes the user and rejects the post" do
other_reviewable =

View File

@ -22,6 +22,10 @@ module PageObjects
find(".modal-footer .btn-primary").click
end
def has_content?(content)
find(".modal-body").has_content?(content)
end
def open?
has_css?(".modal.d-modal")
end

View File

@ -18,6 +18,12 @@ module PageObjects
end
end
def select_action(reviewable, value)
within(reviewable_by_id(reviewable.id)) do
find(".reviewable-action.#{value.dasherize}").click
end
end
def reviewable_by_id(id)
find(".reviewable-item[data-reviewable-id=\"#{id}\"]")
end

View File

@ -76,6 +76,55 @@ describe "Reviewables", type: :system do
expect(queued_post_reviewable.reload).to be_rejected
expect(queued_post_reviewable.target_created_by).to be_nil
end
it "allows revising and rejecting to send a PM to the user" do
revise_modal = PageObjects::Modals::Base.new
review_page.visit_reviewable(queued_post_reviewable)
expect(queued_post_reviewable).to be_pending
expect(queued_post_reviewable.target_created_by).to be_present
review_page.select_action(queued_post_reviewable, "revise_and_reject_post")
expect(revise_modal).to be_open
reason_dropdown =
PageObjects::Components::SelectKit.new(".revise-and-reject-reviewable__reason")
reason_dropdown.select_row_by_value(SiteSetting.reviewable_revision_reasons_map.first)
find(".revise-and-reject-reviewable__feedback").fill_in(with: "This is a test")
revise_modal.click_primary_button
expect(review_page).to have_reviewable_with_rejected_status(queued_post_reviewable)
expect(queued_post_reviewable.reload).to be_rejected
expect(Topic.where(archetype: Archetype.private_message).last.title).to eq(
I18n.t(
"system_messages.reviewable_queued_post_revise_and_reject.subject_template",
topic_title: queued_post_reviewable.topic.title,
),
)
end
it "allows selecting a custom reason for revise and reject" do
revise_modal = PageObjects::Modals::Base.new
review_page.visit_reviewable(queued_post_reviewable)
expect(queued_post_reviewable).to be_pending
expect(queued_post_reviewable.target_created_by).to be_present
review_page.select_action(queued_post_reviewable, "revise_and_reject_post")
expect(revise_modal).to be_open
reason_dropdown =
PageObjects::Components::SelectKit.new(".revise-and-reject-reviewable__reason")
reason_dropdown.select_row_by_value("other_reason")
find(".revise-and-reject-reviewable__custom-reason").fill_in(with: "I felt like it")
find(".revise-and-reject-reviewable__feedback").fill_in(with: "This is a test")
revise_modal.click_primary_button
expect(review_page).to have_reviewable_with_rejected_status(queued_post_reviewable)
end
end
end
end