diff --git a/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.hbs b/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.hbs new file mode 100644 index 00000000000..2c878813bdf --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.hbs @@ -0,0 +1,67 @@ + + <:body> +
+ +
+ +
+ + +
+ + {{#if this.showCustomReason}} +
+ + +
+ {{/if}} + +
+ + +
+ + <:footer> + + + +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.js b/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.js new file mode 100644 index 00000000000..b0cffb5f109 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/revise-and-reject-post-reviewable.js @@ -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; + } + } +} diff --git a/app/assets/javascripts/discourse/app/components/reviewable-item.js b/app/assets/javascripts/discourse/app/components/reviewable-item.js index 9005a0af0fe..8050c122bcb 100644 --- a/app/assets/javascripts/discourse/app/components/reviewable-item.js +++ b/app/assets/javascripts/discourse/app/components/reviewable-item.js @@ -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) => { diff --git a/app/assets/stylesheets/common/base/_index.scss b/app/assets/stylesheets/common/base/_index.scss index 387651c4906..a77ffbd4ce9 100644 --- a/app/assets/stylesheets/common/base/_index.scss +++ b/app/assets/stylesheets/common/base/_index.scss @@ -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"; diff --git a/app/assets/stylesheets/common/base/revise-and-reject-post-reviewable.scss b/app/assets/stylesheets/common/base/revise-and-reject-post-reviewable.scss new file mode 100644 index 00000000000..5548e835bae --- /dev/null +++ b/app/assets/stylesheets/common/base/revise-and-reject-post-reviewable.scss @@ -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; + } + } + } +} diff --git a/app/controllers/reviewables_controller.rb b/app/controllers/reviewables_controller.rb index a00b65a5a85..455cb1ed554 100644 --- a/app/controllers/reviewables_controller.rb +++ b/app/controllers/reviewables_controller.rb @@ -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 = diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index f489156671d..1dbe09130e2 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -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 diff --git a/app/models/reviewable_queued_post.rb b/app/models/reviewable_queued_post.rb index 827087f2efb..d16826888b7 100644 --- a/app/models/reviewable_queued_post.rb +++ b/app/models/reviewable_queued_post.rb @@ -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 diff --git a/app/models/reviewable_user.rb b/app/models/reviewable_user.rb index d7d2d8da098..85f8903357a 100644 --- a/app/models/reviewable_user.rb +++ b/app/models/reviewable_user.rb @@ -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? diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 81c15088f7b..812b1133bb7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 %{username}." claim_help: optional: "You can claim this item to prevent others from reviewing it." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8b9306df9e8..e749d855a82 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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: diff --git a/config/site_settings.yml b/config/site_settings.yml index 82e276775df..9c3d8e12ed2 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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 diff --git a/spec/models/reviewable_queued_post_spec.rb b/spec/models/reviewable_queued_post_spec.rb index ce38df2107c..1414a922625 100644 --- a/spec/models/reviewable_queued_post_spec.rb +++ b/spec/models/reviewable_queued_post_spec.rb @@ -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 = diff --git a/spec/system/page_objects/modals/base.rb b/spec/system/page_objects/modals/base.rb index dbd6cb1622b..daa81e5eea9 100644 --- a/spec/system/page_objects/modals/base.rb +++ b/spec/system/page_objects/modals/base.rb @@ -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 diff --git a/spec/system/page_objects/pages/review.rb b/spec/system/page_objects/pages/review.rb index b80e5580fd2..cfe39046602 100644 --- a/spec/system/page_objects/pages/review.rb +++ b/spec/system/page_objects/pages/review.rb @@ -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 diff --git a/spec/system/reviewables_spec.rb b/spec/system/reviewables_spec.rb index 9bc9b665539..bb4415a219a 100644 --- a/spec/system/reviewables_spec.rb +++ b/spec/system/reviewables_spec.rb @@ -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