FEATURE: the ability to change the order of flags (#27269)

Continued work on moderate flags UI.
In this PR admins are allowed to change the order of flags. The notify user flag is always on top but all other flags can be moved.
This commit is contained in:
Krzysztof Kotlarek 2024-06-05 13:27:06 +10:00 committed by GitHub
parent c1ecbb8d28
commit aa88b07640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 352 additions and 11 deletions

View File

@ -4,13 +4,22 @@ import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import DropdownMenu from "discourse/components/dropdown-menu";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { SYSTEM_FLAG_IDS } from "discourse/lib/constants";
import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
export default class AdminFlagItem extends Component {
@tracked enabled = this.args.flag.enabled;
get canMove() {
return this.args.flag.id !== SYSTEM_FLAG_IDS.notify_user;
}
@action
toggleFlagEnabled(flag) {
this.enabled = !this.enabled;
@ -23,8 +32,25 @@ export default class AdminFlagItem extends Component {
});
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
moveUp() {
this.args.moveFlagCallback(this.args.flag, "up");
this.dMenu.close();
}
@action
moveDown() {
this.args.moveFlagCallback(this.args.flag, "down");
this.dMenu.close();
}
<template>
<tr class="admin-flag-item">
<tr class="admin-flag-item {{@flag.name_key}}">
<td>
<p class="admin-flag-item__name">{{@flag.name}}</p>
<p class="admin-flag-item__description">{{htmlSafe
@ -32,11 +58,46 @@ export default class AdminFlagItem extends Component {
}}</p>
</td>
<td>
<DToggleSwitch
@state={{this.enabled}}
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
<div class="admin-flag-item__options">
<DToggleSwitch
@state={{this.enabled}}
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
{{#if this.canMove}}
<DMenu
@identifier="flag-menu"
@title={{i18n "admin.flags.more_options.title"}}
@icon="ellipsis-v"
@onRegisterApi={{this.onRegisterApi}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#unless @isFirstFlag}}
<dropdown.item>
<DButton
@label="admin.flags.more_options.move_up"
@icon="arrow-up"
@class="btn-transparent move-up"
@action={{this.moveUp}}
/>
</dropdown.item>
{{/unless}}
{{#unless @isLastFlag}}
<dropdown.item>
<DButton
@label="admin.flags.more_options.move_down"
@icon="arrow-down"
@class="btn-transparent move-down"
@action={{this.moveDown}}
/>
</dropdown.item>
{{/unless}}
</DropdownMenu>
</:content>
</DMenu>
{{/if}}
</div>
</td>
</tr>
</template>

View File

@ -1,11 +1,50 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import AdminFlagItem from "admin/components/admin-flag-item";
export default class AdminFlags extends Component {
@service site;
flags = this.site.flagTypes;
@tracked flags = this.site.flagTypes;
@bind
isFirstFlag(flag) {
return this.flags.indexOf(flag) === 1;
}
@bind
isLastFlag(flag) {
return this.flags.indexOf(flag) === this.flags.length - 1;
}
@action
moveFlagCallback(flag, direction) {
const fallbackFlags = [...this.flags];
const flags = this.flags;
const flagIndex = flags.indexOf(flag);
const targetFlagIndex = direction === "up" ? flagIndex - 1 : flagIndex + 1;
const targetFlag = flags[targetFlagIndex];
flags[flagIndex] = targetFlag;
flags[targetFlagIndex] = flag;
this.flags = flags;
return ajax(`/admin/config/flags/${flag.id}/reorder/${direction}`, {
type: "PUT",
}).catch((error) => {
this.flags = fallbackFlags;
return popupAjaxError(error);
});
}
<template>
<div class="container admin-flags">
@ -17,7 +56,12 @@ export default class AdminFlags extends Component {
</thead>
<tbody>
{{#each this.flags as |flag|}}
<AdminFlagItem @flag={{flag}} />
<AdminFlagItem
@flag={{flag}}
@moveFlagCallback={{this.moveFlagCallback}}
@isFirstFlag={{this.isFirstFlag flag}}
@isLastFlag={{this.isLastFlag flag}}
/>
{{/each}}
</tbody>
</table>

View File

@ -80,3 +80,13 @@ export const TOPIC_VISIBILITY_REASONS = {
bulk_action: 5,
unknown: 99,
};
export const SYSTEM_FLAG_IDS = {
like: 2,
notify_user: 6,
notify_moderators: 7,
off_topic: 3,
inappropriate: 4,
spam: 8,
illegal: 10,
};

View File

@ -8,4 +8,14 @@
&__description {
margin-top: 0.5em;
}
&__options {
display: flex;
align-items: center;
}
.d-toggle-switch--label {
margin-bottom: 0;
}
.flag-menu-trigger {
padding: 0.25em 0.325em;
}
}

View File

@ -1,5 +1,6 @@
@import "admin_badges";
@import "admin_customize";
@import "admin_flags";
@import "admin_report_counters";
@import "admin_report_table";
@import "admin_report";

View File

@ -0,0 +1,3 @@
.admin-contents table.grid tr.admin-flag-item {
grid-template-columns: auto min-content;
}

View File

@ -18,4 +18,20 @@ class Admin::Config::FlagsController < Admin::AdminController
def index
end
def reorder
with_service(ReorderFlag) do
on_success do
Discourse.request_refresh!
render(json: success_json)
end
on_failure { render(json: failed_json, status: 422) }
on_model_not_found(:message) { raise Discourse::NotFound }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_policy(:invalid_move) { render_json_error(I18n.t("flags.errors.wrong_move")) }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
end

View File

@ -10,7 +10,7 @@ class Flag < ActiveRecord::Base
after_save :reset_flag_settings!
after_destroy :reset_flag_settings!
default_scope { order(:position) }
default_scope { order(:position).where(score_type: false) }
def used?
PostAction.exists?(post_action_type_id: self.id) ||

View File

@ -45,7 +45,7 @@ class PostActionType < ActiveRecord::Base
end
def all_flags
@all_flags ||= Flag.all
@all_flags ||= Flag.unscoped.order(:position).all
end
def auto_action_flag_types

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
VALID_DIRECTIONS = %w[up down]
class ReorderFlag
include Service::Base
contract
model :flag
policy :invalid_access
policy :invalid_move
transaction do
step :move
step :log
end
class Contract
attribute :flag_id, :integer
attribute :direction, :string
validates :flag_id, presence: true
validates :direction, inclusion: { in: VALID_DIRECTIONS }
end
private
def fetch_flag(flag_id:)
Flag.find(flag_id)
end
def invalid_access(guardian:, flag:)
guardian.can_reorder_flag?(flag)
end
def all_flags
@all_flags ||= Flag.where.not(name_key: "notify_user").order(:position)
end
def invalid_move(flag:, direction:)
return false if all_flags.first == flag && direction == "up"
return false if all_flags.last == flag && direction == "down"
true
end
def move(flag:, direction:)
old_position = flag.position
index = all_flags.index(flag)
target_flag = all_flags[direction == "up" ? index - 1 : index + 1]
flag.update!(position: target_flag.position)
target_flag.update!(position: old_position)
end
def log(guardian:, flag:, direction:)
StaffActionLogger.new(guardian.user).log_custom(
"move_flag",
{ flag: flag.name, direction: direction },
)
end
end

View File

@ -5033,6 +5033,10 @@ en:
title: "Moderation Flags"
description: "Description"
enabled: "Enabled?"
more_options:
title: "More options"
move_up: "Move up"
move_down: "Move down"
groups:
new:
@ -6093,6 +6097,7 @@ en:
update_watched_word_group: "update watched word group"
delete_watched_word_group: "delete watched word group"
toggle_flag: "toggle flag"
move_flag: "move flag"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -1250,6 +1250,7 @@ en:
flags:
errors:
already_handled: "Flag was already handled"
wrong_move: "Flag cannot be moved"
reports:
default:
labels:

View File

@ -388,6 +388,7 @@ Discourse::Application.routes.draw do
namespace :config, constraints: StaffConstraint.new do
resources :flags, only: %i[index] do
put "toggle"
put "reorder/:direction" => "flags#reorder"
end
resources :about, constraints: AdminConstraint.new, only: %i[index] do

View File

@ -54,7 +54,7 @@ Flag.seed do |s|
s.custom_type = true
s.applies_to = %w[Post Topic Chat::Message]
end
Flag.seed do |s|
Flag.unscoped.seed do |s|
s.id = 9
s.name = "needs_approval"
s.position = 6

View File

@ -8,4 +8,8 @@ module FlagGuardian
def can_toggle_flag?
@user.admin?
end
def can_reorder_flag?(flag)
@user.admin? && flag.name_key != "notify_user"
end
end

View File

@ -164,6 +164,8 @@ task "javascript:update_constants" => :environment do
export const MAX_NOTIFICATIONS_LIMIT_PARAMS = #{NotificationsController::INDEX_LIMIT};
export const TOPIC_VISIBILITY_REASONS = #{Topic.visibility_reasons.to_json};
export const SYSTEM_FLAG_IDS = #{PostActionType.types.to_json}
JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -41,4 +41,14 @@ RSpec.describe FlagGuardian do
expect(Guardian.new(user).can_toggle_flag?).to eq(false)
end
end
describe "#can_reorder_flag?" do
it "returns true for admin and false for regular user and notify_user" do
expect(Guardian.new(admin).can_reorder_flag?(Flag.system.last)).to eq(true)
expect(
Guardian.new(admin).can_reorder_flag?(Flag.system.find_by(name_key: "notify_user")),
).to eq(false)
expect(Guardian.new(user).can_reorder_flag?(Flag.system.last)).to eq(false)
end
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
RSpec.describe(ReorderFlag) do
subject(:result) do
described_class.call(flag_id: flag.id, guardian: current_user.guardian, direction: direction)
end
let(:flag) { Flag.order(:position).last }
let(:direction) { "up" }
context "when user is not allowed to perform the action" do
fab!(:current_user) { Fabricate(:user) }
it { is_expected.to fail_a_policy(:invalid_access) }
end
context "when direction is invalid" do
fab!(:current_user) { Fabricate(:admin) }
let(:direction) { "side" }
it { is_expected.to fail_a_contract }
end
context "when move is invalid" do
fab!(:current_user) { Fabricate(:admin) }
let(:direction) { "down" }
it { is_expected.to fail_a_policy(:invalid_move) }
end
context "when user is allowed to perform the action" do
fab!(:current_user) { Fabricate(:admin) }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "moves the flag" do
expect(Flag.order(:position).map(&:name)).to eq(
%w[notify_user notify_moderators off_topic inappropriate spam illegal],
)
result
expect(Flag.order(:position).map(&:name)).to eq(
%w[notify_user notify_moderators off_topic inappropriate illegal spam],
)
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
expect(UserHistory.last).to have_attributes(
custom_type: "move_flag",
details: "flag: #{result[:flag].name}\ndirection: up",
)
end
end
end

View File

@ -26,4 +26,47 @@ describe "Admin Flags Page", type: :system do
["Something Else", "It's Inappropriate", "It's Illegal"],
)
end
it "allows admin to change order of flags" do
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["Something Else", "It's Inappropriate", "It's Spam", "It's Illegal"],
)
visit "/admin/config/flags"
admin_flags_page.move_down("spam")
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["Something Else", "It's Inappropriate", "It's Illegal", "It's Spam"],
)
visit "/admin/config/flags"
admin_flags_page.move_up("spam")
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["Something Else", "It's Inappropriate", "It's Spam", "It's Illegal"],
)
end
it "does not allow to move notify user flag" do
visit "/admin/config/flags"
expect(page).not_to have_css(".notify_user .flag-menu-trigger")
end
it "does not allow bottom flag to move down" do
visit "/admin/config/flags"
admin_flags_page.open_flag_menu("illegal")
expect(page).not_to have_css(".dropdown-menu__item .move-down")
end
it "does not allow top flag to move up" do
visit "/admin/config/flags"
admin_flags_page.open_flag_menu("notify_moderators")
expect(page).not_to have_css(".dropdown-menu__item .move-up")
end
end

View File

@ -6,6 +6,20 @@ module PageObjects
def toggle(key)
PageObjects::Components::DToggleSwitch.new(".admin-flag-item__toggle.#{key}").toggle
end
def open_flag_menu(key)
find(".#{key} .flag-menu-trigger").click
end
def move_down(key)
open_flag_menu(key)
find(".dropdown-menu__item .move-down").click
end
def move_up(key)
open_flag_menu(key)
find(".dropdown-menu__item .move-up").click
end
end
end
end