FEATURE: created edit and delete flags (#27484)

Allow admins to create edit and delete flags.
This commit is contained in:
Krzysztof Kotlarek 2024-07-03 08:45:37 +10:00 committed by GitHub
parent a86590ffd6
commit c3fadc7330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1112 additions and 42 deletions

View File

@ -6,9 +6,10 @@ 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 AdminConfigHeader from "admin/components/admin-config-header";
import AdminFlagItem from "admin/components/admin-flag-item";
export default class AdminFlags extends Component {
export default class AdminConfigAreasFlags extends Component {
@service site;
@tracked flags = this.site.flagTypes;
@ -46,19 +47,39 @@ export default class AdminFlags extends Component {
});
}
@action
deleteFlagCallback(flag) {
return ajax(`/admin/config/flags/${flag.id}`, {
type: "DELETE",
})
.then(() => {
this.flags.removeObject(flag);
})
.catch((error) => popupAjaxError(error));
}
<template>
<div class="container admin-flags">
<h1>{{i18n "admin.flags.title"}}</h1>
<table class="flags grid">
<AdminConfigHeader
@name="flags"
@heading="admin.config_areas.flags.header"
@subheading="admin.config_areas.flags.subheader"
@primaryActionRoute="adminConfig.flags.new"
@primaryActionCssClass="admin-flags__header-add-flag"
@primaryActionIcon="plus"
@primaryActionLabel="admin.config_areas.flags.add"
/>
<table class="admin-flags__items grid">
<thead>
<th>{{i18n "admin.flags.description"}}</th>
<th>{{i18n "admin.flags.enabled"}}</th>
<th>{{i18n "admin.config_areas.flags.description"}}</th>
<th>{{i18n "admin.config_areas.flags.enabled"}}</th>
</thead>
<tbody>
{{#each this.flags as |flag|}}
<AdminFlagItem
@flag={{flag}}
@moveFlagCallback={{this.moveFlagCallback}}
@deleteFlagCallback={{this.deleteFlagCallback}}
@isFirstFlag={{this.isFirstFlag flag}}
@isLastFlag={{this.isLastFlag flag}}
/>

View File

@ -0,0 +1,34 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class AdminFlagItem extends Component {
get headerCssClass() {
return `admin-${this.args.name}__header`;
}
<template>
<div class={{this.headerCssClass}}>
<h2>{{i18n @heading}}</h2>
{{#if @primaryActionRoute}}
<LinkTo
@route={{@primaryActionRoute}}
class={{concatClass
"btn-primary"
"btn"
"btn-icon-text"
@primaryActionCssClass
}}
>
{{dIcon @primaryActionIcon}}
{{i18n @primaryActionLabel}}
</LinkTo>
{{/if}}
{{#if @subheading}}
<h3>{{i18n @subheading}}</h3>
{{/if}}
</div>
</template>
}

View File

@ -3,7 +3,9 @@ import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import DropdownMenu from "discourse/components/dropdown-menu";
@ -14,22 +16,48 @@ import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
export default class AdminFlagItem extends Component {
@service dialog;
@service router;
@tracked enabled = this.args.flag.enabled;
get canMove() {
return this.args.flag.id !== SYSTEM_FLAG_IDS.notify_user;
}
get canEdit() {
return (
!Object.values(SYSTEM_FLAG_IDS).includes(this.args.flag.id) &&
!this.args.flag.is_used
);
}
get editTitle() {
return this.canEdit
? "admin.config_areas.flags.form.edit_flag"
: "admin.config_areas.flags.form.non_editable";
}
get deleteTitle() {
return this.canEdit
? "admin.config_areas.flags.form.edit_flag"
: "admin.config_areas.flags.form.non_editable";
}
@action
toggleFlagEnabled(flag) {
this.enabled = !this.enabled;
return ajax(`/admin/config/flags/${flag.id}/toggle`, {
type: "PUT",
}).catch((error) => {
this.enabled = !this.enabled;
return popupAjaxError(error);
});
})
.then(() => {
this.args.flag.enabled = this.enabled;
})
.catch((error) => {
this.enabled = !this.enabled;
return popupAjaxError(error);
});
}
@action
@ -48,6 +76,23 @@ export default class AdminFlagItem extends Component {
this.args.moveFlagCallback(this.args.flag, "down");
this.dMenu.close();
}
@action
edit() {
this.router.transitionTo("adminConfig.flags.edit", this.args.flag);
}
@action
delete() {
this.dialog.yesNoConfirm({
message: i18n("admin.config_areas.flags.delete_confirm", {
name: this.args.flag.name,
}),
didConfirm: () => {
this.args.deleteFlagCallback(this.args.flag);
},
});
this.dMenu.close();
}
<template>
<tr class="admin-flag-item {{@flag.name_key}}">
@ -64,10 +109,19 @@ export default class AdminFlagItem extends Component {
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
<DButton
class="btn btn-secondary admin-flag-item__edit"
@action={{this.edit}}
@label="admin.config_areas.flags.edit"
@disabled={{not this.canEdit}}
@title={{this.editTitle}}
/>
{{#if this.canMove}}
<DMenu
@identifier="flag-menu"
@title={{i18n "admin.flags.more_options.title"}}
@title={{i18n "admin.config_areas.flags.more_options.title"}}
@icon="ellipsis-v"
@onRegisterApi={{this.onRegisterApi}}
>
@ -76,9 +130,9 @@ export default class AdminFlagItem extends Component {
{{#unless @isFirstFlag}}
<dropdown.item>
<DButton
@label="admin.flags.more_options.move_up"
@label="admin.config_areas.flags.more_options.move_up"
@icon="arrow-up"
@class="btn-transparent move-up"
@class="btn-transparent admin-flag-item__move-up"
@action={{this.moveUp}}
/>
</dropdown.item>
@ -86,13 +140,24 @@ export default class AdminFlagItem extends Component {
{{#unless @isLastFlag}}
<dropdown.item>
<DButton
@label="admin.flags.more_options.move_down"
@label="admin.config_areas.flags.more_options.move_down"
@icon="arrow-down"
@class="btn-transparent move-down"
@class="btn-transparent admin-flag-item__move-down"
@action={{this.moveDown}}
/>
</dropdown.item>
{{/unless}}
<dropdown.item>
<DButton
@label="admin.config_areas.flags.delete"
@icon="trash-alt"
class="btn-transparent admin-flag-item__delete"
@action={{this.delete}}
@disabled={{not this.canEdit}}
@title={{this.deleteTitle}}
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>

View File

@ -0,0 +1,190 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { hash } from "@ember/helper";
import { TextArea } from "@ember/legacy-built-in-components";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import MultiSelect from "select-kit/components/multi-select";
export default class AdminFlagsForm extends Component {
@service router;
@service site;
@tracked enabled = true;
@tracked name;
@tracked description;
@tracked appliesTo;
constructor() {
super(...arguments);
if (this.isUpdate) {
this.name = this.args.flag.name;
this.description = this.args.flag.description;
this.appliesTo = this.args.flag.applies_to;
this.enabled = this.args.flag.enabled;
}
}
get isUpdate() {
return this.args.flag;
}
get isValid() {
return (
!isEmpty(this.name) &&
!isEmpty(this.description) &&
!isEmpty(this.appliesTo)
);
}
get header() {
return this.isUpdate
? "admin.config_areas.flags.form.edit_header"
: "admin.config_areas.flags.form.add_header";
}
get appliesToValues() {
return this.site.valid_flag_applies_to_types.map((type) => {
return {
name: I18n.t(
`admin.config_areas.flags.form.${type
.toLowerCase()
.replace("::", "_")}`
),
id: type,
};
});
}
@action
save() {
this.isUpdate ? this.update() : this.create();
}
@bind
create() {
return ajax(`/admin/config/flags`, {
type: "POST",
data: this.#formData,
})
.then((response) => {
this.site.flagTypes.push(response.flag);
this.router.transitionTo("adminConfig.flags");
})
.catch((error) => {
return popupAjaxError(error);
});
}
@bind
update() {
return ajax(`/admin/config/flags/${this.args.flag.id}`, {
type: "PUT",
data: this.#formData,
})
.then((response) => {
this.args.flag.name = response.flag.name;
this.args.flag.description = response.flag.description;
this.args.flag.applies_to = response.flag.applies_to;
this.args.flag.enabled = response.flag.enabled;
this.router.transitionTo("adminConfig.flags");
})
.catch((error) => {
return popupAjaxError(error);
});
}
@bind
get #formData() {
return {
name: this.name,
description: this.description,
applies_to: this.appliesTo,
enabled: this.enabled,
};
}
<template>
<div class="admin-config-area">
<h2>{{i18n "admin.config_areas.flags.header"}}</h2>
<LinkTo
@route="adminConfig.flags"
class="btn-default btn btn-icon-text btn-back"
>
{{dIcon "chevron-left"}}
{{i18n "admin.config_areas.flags.back"}}
</LinkTo>
<div class="admin-config-area__primary-content admin-flag-form">
<AdminConfigAreaCard @heading={{this.header}}>
<div class="control-group">
<label for="name">
{{i18n "admin.config_areas.flags.form.name"}}
</label>
<Input
name="name"
@type="text"
@value={{this.name}}
maxlength="200"
class="admin-flag-form__name"
/>
</div>
<div class="control-group">
<label for="description">
{{i18n "admin.config_areas.flags.form.description"}}
</label>
<TextArea
@value={{this.description}}
maxlength="1000"
class="admin-flag-form__description"
/>
</div>
<div class="control-group">
<label for="applies-to">
{{i18n "admin.config_areas.flags.form.applies_to"}}
</label>
<MultiSelect
@value={{this.appliesTo}}
@content={{this.appliesToValues}}
@options={{hash allowAny=false}}
class="admin-flag-form__applies-to"
/>
</div>
<div class="control-group">
<label class="checkbox-label admin-flag-form__enabled">
<Input @type="checkbox" @checked={{this.enabled}} />
{{i18n "admin.config_areas.flags.form.enabled"}}
</label>
</div>
<div class="alert alert-info admin_flag_form__info">
{{dIcon "info-circle"}}
{{i18n "admin.config_areas.flags.form.alert"}}
</div>
<DButton
@action={{this.save}}
@label="admin.config_areas.flags.form.save"
@ariaLabel="admin.config_areas.flags.form.save"
@disabled={{not this.isValid}}
class="btn-primary admin-flag-form__save"
/>
</AdminConfigAreaCard>
</div>
</div>
</template>
}

View File

@ -0,0 +1,10 @@
import Route from "@ember/routing/route";
import { service } from "@ember/service";
export default class AdminConfigFlagsEditRoute extends Route {
@service site;
model(params) {
return this.site.flagTypes.findBy("id", parseInt(params.flag_id, 10));
}
}

View File

@ -215,6 +215,8 @@ export default function () {
function () {
this.route("flags", function () {
this.route("index", { path: "/" });
this.route("new");
this.route("edit", { path: "/:flag_id" });
});
this.route("about");

View File

@ -0,0 +1 @@
<AdminFlagsForm @flag={{@model}} />

View File

@ -1 +1 @@
<AdminFlags />
<AdminConfigAreas::Flags />

View File

@ -0,0 +1 @@
<AdminFlagsForm />

View File

@ -1,10 +1,11 @@
import { LinkTo } from "@ember/routing";
import { or } from "truth-helpers";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
<template>
<LinkTo class="btn btn-flat back-button" @route={{@route}}>
{{dIcon "chevron-left"}}
{{i18n "back_button"}}
{{i18n (or @label "back_button")}}
</LinkTo>
</template>

View File

@ -20,6 +20,10 @@ export default class PostFlag extends Flag {
flagsAvailable(flagModal) {
let flagsAvailable = flagModal.args.model.flagModel.flagsAvailable;
flagsAvailable = flagsAvailable.filter((flag) => {
return flag.applies_to.includes("Post");
});
// "message user" option should be at the top
const notifyUserIndex = flagsAvailable.indexOf(
flagsAvailable.filterBy("name_key", "notify_user")[0]

View File

@ -590,7 +590,8 @@ export default {
icon: null,
id: 3,
is_custom_flag: false,
enabled: true
enabled: true,
applies_to: ["Post", "Chat::Message"]
},
{
name_key: "inappropriate",
@ -603,7 +604,8 @@ export default {
icon: null,
id: 4,
is_custom_flag: false,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
{
name_key: "vote",
@ -626,7 +628,8 @@ export default {
icon: null,
id: 8,
is_custom_flag: false,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
{
name_key: "notify_user",
@ -639,7 +642,8 @@ export default {
icon: null,
id: 6,
is_custom_flag: true,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
{
name_key: "notify_moderators",
@ -651,7 +655,8 @@ export default {
icon: null,
id: 7,
is_custom_flag: true,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
],
topic_flag_types: [
@ -664,7 +669,8 @@ export default {
icon: null,
id: 4,
is_custom_flag: false,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
{
name_key: "spam",
@ -675,7 +681,8 @@ export default {
icon: null,
id: 8,
is_custom_flag: false,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
{
name_key: "notify_moderators",
@ -686,7 +693,8 @@ export default {
icon: null,
id: 7,
is_custom_flag: true,
enabled: true
enabled: true,
applies_to: ["Post", "Topic", "Chat::Message"]
},
],
archetypes: [

View File

@ -1,3 +1,8 @@
.admin-config-area {
.btn-back {
margin-bottom: 1em;
}
}
.admin-config-area-card {
padding: 20px;
border: 1px solid var(--primary-low);
@ -22,4 +27,11 @@
font-size: var(--font-down-1);
padding: 10px 10px;
}
&__control-group-horizontal {
display: flex;
margin-bottom: 18px;
label {
margin-right: 1em;
}
}
}

View File

@ -10,11 +10,67 @@
&__options {
display: flex;
align-items: center;
justify-content: space-between;
}
.d-toggle-switch--label {
margin-bottom: 0;
}
.d-toggle-switch {
margin-right: 2em;
}
.btn-secondary {
padding: 0.25em 0.325em;
margin-right: 0.75em;
}
.flag-menu-trigger {
padding: 0.25em 0.325em;
}
&__delete.btn,
&__delete.btn:hover {
border-top: 1px solid var(--primary-low);
color: var(--danger);
svg {
color: var(--danger);
}
}
}
.admin-flag-form {
&__enabled,
&__applies-to {
margin-bottom: 1em;
}
&__save {
margin-top: 1em;
}
&__info {
color: var(--primary-high);
svg {
color: var(--tertiary);
}
}
&__description {
width: 60%;
}
&__applies-to.select-kit.multi-select {
width: 60%;
}
}
.admin-flags__header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
.btn-primary {
align-self: center;
}
h3 {
margin-top: 1em;
font-size: var(--font-0);
font-weight: normal;
flex-basis: 100%;
}
}

View File

@ -1,3 +1,17 @@
.admin-contents table.grid tr.admin-flag-item {
grid-template-columns: auto min-content;
.d-toggle-switch {
margin-right: 0;
}
}
.admin-config-area__primary-content {
.admin-flag-form {
&__name,
&__applies-to.select-kit.multi-select,
&__description {
width: 100%;
}
}
}

View File

@ -2,7 +2,7 @@
class Admin::Config::FlagsController < Admin::AdminController
def toggle
with_service(ToggleFlag) do
with_service(Flags::ToggleFlag) do
on_success do
Discourse.request_refresh!
render(json: success_json)
@ -19,8 +19,45 @@ class Admin::Config::FlagsController < Admin::AdminController
def index
end
def new
end
def edit
end
def create
with_service(Flags::CreateFlag) do
on_success do
Discourse.request_refresh!
render json: result.flag, serializer: FlagSerializer
end
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
def update
with_service(Flags::UpdateFlag) do
on_success do
Discourse.request_refresh!
render json: result.flag, serializer: FlagSerializer
end
on_failure { render(json: failed_json, status: 422) }
on_model_not_found(:message) { raise Discourse::NotFound }
on_failed_policy(:not_system) { render_json_error(I18n.t("flags.errors.system")) }
on_failed_policy(:not_used) { render_json_error(I18n.t("flags.errors.used")) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
def reorder
with_service(ReorderFlag) do
with_service(Flags::ReorderFlag) do
on_success do
Discourse.request_refresh!
render(json: success_json)
@ -34,4 +71,20 @@ class Admin::Config::FlagsController < Admin::AdminController
end
end
end
def destroy
with_service(Flags::DestroyFlag) do
on_success do
Discourse.request_refresh!
render(json: success_json)
end
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:not_system) { render_json_error(I18n.t("flags.errors.system")) }
on_failed_policy(:not_used) { render_json_error(I18n.t("flags.errors.used")) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
end

View File

@ -1,7 +1,10 @@
# frozen_string_literal: true
class Flag < ActiveRecord::Base
DEFAULT_VALID_APPLIES_TO = %w[Post Topic]
MAX_SYSTEM_FLAG_ID = 1000
MAX_NAME_LENGTH = 200
MAX_DESCRIPTION_LENGTH = 1000
scope :enabled, -> { where(enabled: true) }
scope :system, -> { where("id < 1000") }
@ -17,6 +20,10 @@ class Flag < ActiveRecord::Base
ReviewableScore.exists?(reviewable_score_type: self.id)
end
def self.valid_applies_to_types
Set.new(DEFAULT_VALID_APPLIES_TO | DiscoursePluginRegistry.flag_applies_to_types)
end
def self.reset_flag_settings!
# Flags are memoized for better performance. After the update, we need to reload them in all processes.
PostActionType.reload_types

View File

@ -117,6 +117,14 @@ class PostActionType < ActiveRecord::Base
all_flags.pluck(:id, :name).to_h
end
def descriptions
all_flags.pluck(:id, :description).to_h
end
def applies_to
all_flags.pluck(:id, :applies_to).to_h
end
def is_flag?(sym)
flag_types.valid?(sym)
end

View File

@ -196,6 +196,11 @@ class Reviewable < ActiveRecord::Base
update(score: self.score + rs.score, latest_score: rs.created_at, force_review: force_review)
topic.update(reviewable_score: topic.reviewable_score + rs.score) if topic
# Flags are cached for performance reasons.
# However, when the reviewable item is created, we need to clear the cache to mark flag as used.
# Used flags cannot be deleted or update by admins, only disabled.
Flag.reset_flag_settings! if PostActionType.notify_flag_type_ids.include?(reviewable_score_type)
DiscourseEvent.trigger(:reviewable_score_updated, self)
rs

View File

@ -10,6 +10,8 @@ class PostActionTypeSerializer < ApplicationSerializer
:is_flag,
:is_custom_flag,
:enabled,
:applies_to,
:is_used,
)
include ConfigurableUrls
@ -27,7 +29,14 @@ class PostActionTypeSerializer < ApplicationSerializer
end
def description
i18n("description", vars: { tos_url:, base_path: Discourse.base_path })
i18n(
"description",
vars: {
tos_url:,
base_path: Discourse.base_path,
},
default: object.class.descriptions[object.id],
)
end
def short_description
@ -42,6 +51,15 @@ class PostActionTypeSerializer < ApplicationSerializer
!!PostActionType.enabled_flag_types[object.id]
end
def applies_to
Array.wrap(PostActionType.applies_to[object.id])
end
def is_used
PostAction.exists?(post_action_type_id: object.id) ||
ReviewableScore.exists?(reviewable_score_type: object.id)
end
protected
def i18n(field, default: nil, vars: nil)

View File

@ -10,8 +10,7 @@ class ReviewableScoreTypeSerializer < ApplicationSerializer
# Allow us to share post action type translations for backwards compatibility
def title
I18n.t("post_action_types.#{type}.title", default: nil) ||
I18n.t("reviewable_score_types.#{type}.title", default: nil) ||
PostActionType.flag_settings.names[id]
I18n.t("reviewable_score_types.#{type}.title", default: nil) || PostActionType.names[id]
end
def reviewable_priority

View File

@ -48,6 +48,7 @@ class SiteSerializer < ApplicationSerializer
:privacy_policy_url,
:system_user_avatar_template,
:lazy_load_categories,
:valid_flag_applies_to_types,
)
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@ -350,6 +351,14 @@ class SiteSerializer < ApplicationSerializer
scope.can_lazy_load_categories?
end
def valid_flag_applies_to_types
Flag.valid_applies_to_types
end
def include_valid_flag_applies_to_types?
scope.is_admin?
end
private
def ordered_flags(flags)

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
class Flags::CreateFlag
include Service::Base
contract
policy :invalid_access
model :flag, :instantiate_flag
transaction do
step :create
step :log
end
class Contract
attribute :name, :string
attribute :description, :string
attribute :enabled, :boolean
attribute :applies_to
validates :name, presence: true
validates :description, presence: true
validates :name, length: { maximum: Flag::MAX_NAME_LENGTH }
validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH }
validates :applies_to, inclusion: { in: Flag.valid_applies_to_types }, allow_nil: false
end
private
def instantiate_flag(name:, description:, applies_to:, enabled:)
Flag.new(
name: name,
description: description,
applies_to: applies_to,
enabled: enabled,
notify_type: true,
)
end
def invalid_access(guardian:)
guardian.can_create_flag?
end
def create(flag:)
flag.save!
end
def log(guardian:, flag:)
StaffActionLogger.new(guardian.user).log_custom(
"create_flag",
{
name: flag.name,
description: flag.description,
applies_to: flag.applies_to,
enabled: flag.enabled,
},
)
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
class Flags::DestroyFlag
include Service::Base
model :flag
policy :not_system
policy :not_used
policy :invalid_access
transaction do
step :destroy
step :log
end
private
def fetch_flag(id:)
Flag.find(id)
end
def not_system(flag:)
!flag.system?
end
def not_used(flag:)
!flag.used?
end
def invalid_access(guardian:, flag:)
guardian.can_edit_flag?(flag)
end
def destroy(flag:)
flag.destroy!
end
def log(guardian:, flag:)
StaffActionLogger.new(guardian.user).log_custom(
"delete_flag",
{
name: flag.name,
description: flag.description,
applies_to: flag.applies_to,
enabled: flag.enabled,
},
)
end
end

View File

@ -2,7 +2,7 @@
VALID_DIRECTIONS = %w[up down]
class ReorderFlag
class Flags::ReorderFlag
include Service::Base
contract

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ToggleFlag
class Flags::ToggleFlag
include Service::Base
contract

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
class Flags::UpdateFlag
include Service::Base
contract
model :flag
policy :not_system
policy :not_used
policy :invalid_access
transaction do
step :update
step :log
end
class Contract
attribute :name, :string
attribute :description, :string
attribute :enabled, :boolean
attribute :applies_to
validates :name, presence: true
validates :description, presence: true
validates :name, length: { maximum: Flag::MAX_NAME_LENGTH }
validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH }
validates :applies_to, inclusion: { in: Flag.valid_applies_to_types }, allow_nil: false
end
private
def fetch_flag(id:)
Flag.find(id)
end
def not_system(flag:)
!flag.system?
end
def not_used(flag:)
!flag.used?
end
def invalid_access(guardian:, flag:)
guardian.can_edit_flag?(flag)
end
def update(flag:, name:, description:, applies_to:, enabled:)
flag.update!(name: name, description: description, applies_to: applies_to, enabled: enabled)
end
def log(guardian:, flag:)
StaffActionLogger.new(guardian.user).log_custom(
"update_flag",
{
name: flag.name,
description: flag.description,
applies_to: flag.applies_to,
enabled: flag.enabled,
},
)
end
end

View File

@ -5048,7 +5048,6 @@ en:
title: "More options"
move_up: "Move up"
move_down: "Move down"
groups:
new:
title: "New Group"
@ -5480,6 +5479,36 @@ en:
contact_information_saved: "Contact information saved"
your_organization_saved: "Your organization saved"
saved: "saved!"
flags:
header: "Moderation Flags"
subheader: "The flagging system in Discourse helps you and your moderator team manage content and user behavior, keeping your community respectful and healthy. The defaults are suitable for most communities and you dont have to change them. However, if your site has particular requirements you can disable flags you dont need and add your own custom flags."
description: "Description"
enabled: "Enabled?"
add: "Add Flag"
edit: "Edit"
back: "Back to flags"
delete: "Delete"
delete_confirm: 'Are you sure you want to delete "%{name}"?'
form:
add_header: "Add flag"
edit_header: "Edit flag"
save: "Save"
name: "Name"
description: "Description"
applies_to: "Display this flag on"
topic: "topics"
post: "posts"
chat_message: "chat messages"
enabled: "Enable this custom flag after saving"
alert: "Once a custom flag has been used, it can only be disabled but not edited or deleted."
edit_flag: "Edit flag"
non_editable: "You cannot edit this flag because it is a system flag or has already been used in the review system, however you can still disable it."
delete_flag: "Delete flag"
non_deletable: "You cannot delete this flag because it is a system flag or has already been used in the review system, however you can still disable it."
more_options:
title: "More options"
move_up: "Move up"
move_down: "Move down"
plugins:
title: "Plugins"
installed: "Installed Plugins"
@ -6157,6 +6186,9 @@ en:
delete_watched_word_group: "delete watched word group"
toggle_flag: "toggle flag"
move_flag: "move flag"
create_flag: "create flag"
update_flag: "update flag"
delete_flag: "delete 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

@ -1253,6 +1253,8 @@ en:
errors:
already_handled: "Flag was already handled"
wrong_move: "Flag cannot be moved"
system: "System flag cannot be updated or deleted."
used: "Flag cannot be updated or deleted because has already been used."
reports:
default:
labels:

View File

@ -386,9 +386,10 @@ Discourse::Application.routes.draw do
end
end
namespace :config, constraints: StaffConstraint.new do
resources :flags, only: %i[index] do
resources :flags, only: %i[index new create update destroy] do
put "toggle"
put "reorder/:direction" => "flags#reorder"
member { get "/" => "flags#edit" }
end
resources :about, constraints: AdminConstraint.new, only: %i[index] do

View File

@ -123,6 +123,8 @@ class DiscoursePluginRegistry
define_filtered_register :problem_checks
define_filtered_register :flag_applies_to_types
def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider
end

View File

@ -5,6 +5,10 @@ module FlagGuardian
@user.admin? && !flag.system? && !flag.used?
end
def can_create_flag?
@user.admin?
end
def can_toggle_flag?
@user.admin?
end

View File

@ -37,7 +37,7 @@ export default class ChatMessageFlag {
"description",
I18n.t(`chat.flags.${flag.name_key}`, {
basePath: getURL(""),
defaultValue: "",
defaultValue: flag.description,
})
);
return flag;
@ -48,8 +48,9 @@ export default class ChatMessageFlag {
let flagsAvailable = flagModal.site.flagTypes;
flagsAvailable = flagsAvailable.filter((flag) => {
return flagModal.args.model.flagModel.availableFlags.includes(
flag.name_key
return (
flagModal.args.model.flagModel.availableFlags.includes(flag.name_key) &&
flag.applies_to.includes("Chat::Message")
);
});

View File

@ -49,6 +49,7 @@ after_initialize do
register_user_custom_field_type(Chat::LAST_CHAT_CHANNEL_ID, :integer)
DiscoursePluginRegistry.serialized_current_user_fields << Chat::LAST_CHAT_CHANNEL_ID
DiscoursePluginRegistry.register_flag_applies_to_type("Chat::Message", self)
UserUpdater::OPTION_ATTR.push(:chat_enabled)
UserUpdater::OPTION_ATTR.push(:only_chat_push_notifications)

View File

@ -356,6 +356,12 @@
},
"enabled": {
"type": "boolean"
},
"applies_to": {
"type": "array"
},
"is_used": {
"type": "boolean"
}
},
"required": [
@ -366,7 +372,9 @@
"short_description",
"is_flag",
"is_custom_flag",
"enabled"
"enabled",
"applies_to",
"is_used"
]
}
},
@ -400,6 +408,12 @@
},
"enabled": {
"type": "boolean"
},
"applies_to": {
"type": "array"
},
"is_used": {
"type": "boolean"
}
},
"required": [
@ -410,7 +424,9 @@
"short_description",
"is_flag",
"is_custom_flag",
"enabled"
"enabled",
"applies_to",
"is_used"
]
}
},
@ -823,6 +839,9 @@
"denied_emojis" : {
"type": "array"
},
"valid_flag_applies_to_types" : {
"type": "array"
},
"navigation_menu_site_top_tags": {
"type": "array"
}

View File

@ -0,0 +1,87 @@
# frozen_string_literal: true
RSpec.describe(Flags::CreateFlag) do
subject(:result) do
described_class.call(
guardian: current_user.guardian,
name: name,
description: description,
applies_to: applies_to,
enabled: enabled,
)
end
let(:name) { "custom flag name" }
let(:description) { "custom flag description" }
let(:applies_to) { ["Topic"] }
let(:enabled) { true }
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 applies to is invalid" do
fab!(:current_user) { Fabricate(:admin) }
let(:applies_to) { ["User"] }
it { is_expected.to fail_a_contract }
end
context "when title is empty" do
fab!(:current_user) { Fabricate(:admin) }
let(:name) { nil }
it { is_expected.to fail_a_contract }
end
context "when title is too long" do
fab!(:current_user) { Fabricate(:admin) }
let(:name) { "a" * 201 }
it { is_expected.to fail_a_contract }
end
context "when description is empty" do
fab!(:current_user) { Fabricate(:admin) }
let(:description) { nil }
it { is_expected.to fail_a_contract }
end
context "when description is too long" do
fab!(:current_user) { Fabricate(:admin) }
let(:description) { "a" * 1001 }
it { is_expected.to fail_a_contract }
end
context "when user is allowed to perform the action" do
fab!(:current_user) { Fabricate(:admin) }
after { Flag.destroy_by(name: "custom flag name") }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "creates the flag" do
result
flag = Flag.last
expect(flag.name).to eq("custom flag name")
expect(flag.description).to eq("custom flag description")
expect(flag.applies_to).to eq(["Topic"])
expect(flag.enabled).to be true
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
expect(UserHistory.last).to have_attributes(
custom_type: "create_flag",
details:
"name: custom flag name\ndescription: custom flag description\napplies_to: [\"Topic\"]\nenabled: true",
)
end
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
RSpec.describe(Flags::DestroyFlag) do
fab!(:flag)
subject(:result) { described_class.call(id: flag.id, guardian: current_user.guardian) }
after { flag.destroy }
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 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 "destroys the flag" do
result
expect { flag.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
expect(UserHistory.last).to have_attributes(
custom_type: "delete_flag",
details:
"name: offtopic\ndescription: \napplies_to: [\"Post\", \"Chat::Message\"]\nenabled: true",
)
end
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe(ReorderFlag) do
RSpec.describe(Flags::ReorderFlag) do
subject(:result) do
described_class.call(flag_id: flag.id, guardian: current_user.guardian, direction: direction)
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
RSpec.describe(ToggleFlag) do
RSpec.describe(Flags::ToggleFlag) do
subject(:result) { described_class.call(flag_id: flag.id, guardian: current_user.guardian) }
let(:flag) { Flag.system.last }

View File

@ -0,0 +1,89 @@
# frozen_string_literal: true
RSpec.describe(Flags::UpdateFlag) do
fab!(:flag)
subject(:result) do
described_class.call(
id: flag.id,
guardian: current_user.guardian,
name: name,
description: description,
applies_to: applies_to,
enabled: enabled,
)
end
after { flag.destroy }
let(:name) { "edited custom flag name" }
let(:description) { "edited custom flag description" }
let(:applies_to) { ["Topic"] }
let(:enabled) { false }
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 applies to is invalid" do
fab!(:current_user) { Fabricate(:admin) }
let(:applies_to) { ["User"] }
it { is_expected.to fail_a_contract }
end
context "when title is empty" do
fab!(:current_user) { Fabricate(:admin) }
let(:name) { nil }
it { is_expected.to fail_a_contract }
end
context "when title is too long" do
fab!(:current_user) { Fabricate(:admin) }
let(:name) { "a" * 201 }
it { is_expected.to fail_a_contract }
end
context "when description is empty" do
fab!(:current_user) { Fabricate(:admin) }
let(:description) { nil }
it { is_expected.to fail_a_contract }
end
context "when description is too long" do
fab!(:current_user) { Fabricate(:admin) }
let(:description) { "a" * 1001 }
it { is_expected.to fail_a_contract }
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 "updates the flag" do
result
expect(flag.reload.name).to eq("edited custom flag name")
expect(flag.description).to eq("edited custom flag description")
expect(flag.applies_to).to eq(["Topic"])
expect(flag.enabled).to be false
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
expect(UserHistory.last).to have_attributes(
custom_type: "update_flag",
details:
"name: edited custom flag name\ndescription: edited custom flag description\napplies_to: [\"Topic\"]\nenabled: false",
)
end
end
end

View File

@ -7,6 +7,7 @@ describe "Admin Flags Page", type: :system do
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:admin_flags_page) { PageObjects::Pages::AdminFlags.new }
let(:admin_flag_form_page) { PageObjects::Pages::AdminFlagForm.new }
before { sign_in(admin) }
@ -55,6 +56,55 @@ describe "Admin Flags Page", type: :system do
)
end
it "allows admin to create, edit and delete 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(
["It's Inappropriate", "It's Spam", "It's Illegal", "Something Else"],
)
visit "/admin/config/flags"
admin_flags_page.click_add_flag
expect(admin_flag_form_page).to have_disabled_save_button
admin_flag_form_page.fill_in_name("Vulgar")
admin_flag_form_page.fill_in_description("New flag description")
admin_flag_form_page.fill_in_applies_to("Topic")
admin_flag_form_page.fill_in_applies_to("Post")
admin_flag_form_page.click_save
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["It's Inappropriate", "It's Spam", "It's Illegal", "Something Else", "Vulgar"],
)
visit "/admin/config/flags"
admin_flags_page.click_edit_flag("vulgar")
admin_flag_form_page.fill_in_name("Tasteless")
admin_flag_form_page.click_save
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["It's Inappropriate", "It's Spam", "It's Illegal", "Something Else", "Tasteless"],
)
visit "/admin/config/flags"
admin_flags_page.click_delete_flag("tasteless")
admin_flags_page.confirm_delete
topic_page.visit_topic(post.topic)
topic_page.open_flag_topic_modal
expect(all(".flag-action-type-details strong").map(&:text)).to eq(
["It's Inappropriate", "It's Spam", "It's Illegal", "Something Else"],
)
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")
@ -66,6 +116,17 @@ describe "Admin Flags Page", type: :system do
expect(page).not_to have_css(".dropdown-menu__item .move-down")
end
it "does not allow to system flag to be edited" do
visit "/admin/config/flags"
expect(page).to have_css(".off_topic .admin-flag-item__edit[disabled]")
end
it "does not allow to system flag to be deleted" do
visit "/admin/config/flags"
admin_flags_page.open_flag_menu("notify_moderators")
expect(page).to have_css(".admin-flag-item__delete[disabled]")
end
it "does not allow top flag to move up" do
visit "/admin/config/flags"
admin_flags_page.open_flag_menu("off_topic")

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminFlagForm < PageObjects::Pages::Base
def has_disabled_save_button?
find_button("Save", disabled: true)
end
def fill_in_name(name)
find(".admin-flag-form__name").fill_in(with: name)
end
def fill_in_description(description)
find(".admin-flag-form__description").fill_in(with: description)
end
def fill_in_applies_to(applies_to)
dropdown = PageObjects::Components::SelectKit.new(".admin-flag-form__applies-to")
dropdown.expand
dropdown.select_row_by_value(applies_to)
dropdown.collapse
end
def click_save
find(".admin-flag-form__save").click
end
end
end
end

View File

@ -13,12 +13,29 @@ module PageObjects
def move_down(key)
open_flag_menu(key)
find(".dropdown-menu__item .move-down").click
find(".admin-flag-item__move-down").click
end
def move_up(key)
open_flag_menu(key)
find(".dropdown-menu__item .move-up").click
find(".admin-flag-item__move-up").click
end
def click_add_flag
find(".admin-flags__header-add-flag").click
end
def click_edit_flag(key)
find(".#{key} .admin-flag-item__edit").click
end
def click_delete_flag(key)
find(".#{key} .flag-menu-trigger").click
find(".admin-flag-item__delete").click
end
def confirm_delete
find(".dialog-footer .btn-primary").click
end
end
end