FEATURE: Add attribution to staff notice and rename functionality (#30920)

The name "Staff Notice" was not quite right since TL4 users
can also add these notices. This commit changes the wording to
"Official Notice".

In addition to this, currently you have to go look into the staff
action logs to see who is responsible for a notice. This commit
stores the ID of the user who created the notice, then shows this
information on each notice to staff users.

Finally, I migrated the ChangePostNoticeModal component to gjs.
This commit is contained in:
Martin Brennan 2025-01-24 09:29:22 +10:00 committed by GitHub
parent 692fccb0d9
commit 1b9e2ff4f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 308 additions and 121 deletions

View File

@ -0,0 +1,110 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel";
import withEventValue from "discourse/helpers/with-event-value";
import { i18n } from "discourse-i18n";
export default class ChangePostNoticeModal extends Component {
@service currentUser;
@tracked post = this.args.model.post;
@tracked notice = this.args.model.post.notice?.raw ?? "";
@tracked saving = false;
resolve = this.args.model.resolve;
reject = this.args.model.reject;
get disabled() {
return (
this.saving ||
isEmpty(this.notice) ||
this.notice === this.post.notice?.raw
);
}
@action
saveNotice() {
this.setNotice(this.notice);
}
@action
deleteNotice() {
this.setNotice();
}
@action
setNotice(notice) {
const { resolve, reject } = this;
this.saving = true;
this.resolve = null;
this.reject = null;
this.post
.updatePostField("notice", notice)
.then((response) => {
if (notice) {
return response.cooked_notice;
}
})
.then((cooked) => {
this.post.set(
"notice",
cooked
? {
type: "custom",
raw: notice,
cooked: cooked.toString(),
}
: null
);
this.post.set("noticeCreatedByUser", this.currentUser);
})
.then(resolve, reject)
.finally(() => this.args.closeModal());
}
<template>
<DModal
@title={{if
@model.post.notice
(i18n "post.controls.change_post_notice")
(i18n "post.controls.add_post_notice")
}}
@closeModal={{@closeModal}}
class="change-post-notice-modal"
>
<:body>
<form>
<textarea
value={{this.notice}}
{{on "input" (withEventValue (fn (mut this.notice)))}}
/>
</form>
</:body>
<:footer>
<DButton
@label={{if this.saving "saving" "save"}}
@action={{fn this.setNotice this.notice}}
@disabled={{this.disabled}}
class="btn-primary"
/>
{{#if @model.post.notice}}
<DButton
@label="post.controls.delete_post_notice"
@action={{this.setNotice}}
@disabled={{this.saving}}
class="btn-danger"
/>
{{/if}}
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>
</template>
}

View File

@ -1,30 +0,0 @@
<DModal
@title={{if
@model.post.notice
(i18n "post.controls.change_post_notice")
(i18n "post.controls.add_post_notice")
}}
@closeModal={{@closeModal}}
class="change-post-notice-modal"
>
<:body>
<form><Textarea @value={{this.notice}} /></form>
</:body>
<:footer>
<DButton
@label={{if this.saving "saving" "save"}}
@action={{fn this.setNotice this.notice}}
@disabled={{this.disabled}}
class="btn-primary"
/>
{{#if @model.post.notice}}
<DButton
@label="post.controls.delete_post_notice"
@action={{this.setNotice}}
@disabled={{this.saving}}
class="btn-danger"
/>
{{/if}}
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>

View File

@ -1,63 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { isEmpty } from "@ember/utils";
import { cook } from "discourse/lib/text";
export default class ChangePostNoticeModal extends Component {
@tracked post = this.args.model.post;
@tracked notice = this.args.model.post.notice?.raw ?? "";
@tracked saving = false;
resolve = this.args.model.resolve;
reject = this.args.model.reject;
get disabled() {
return (
this.saving ||
isEmpty(this.notice) ||
this.notice === this.post.notice?.raw
);
}
@action
saveNotice() {
this.setNotice(this.notice);
}
@action
deleteNotice() {
this.setNotice();
}
@action
setNotice(notice) {
const { resolve, reject } = this;
this.saving = true;
this.resolve = null;
this.reject = null;
this.post
.updatePostField("notice", notice)
.then(() => {
if (notice) {
return cook(notice, { features: { onebox: false } });
}
})
.then((cooked) =>
this.post.set(
"notice",
cooked
? {
type: "custom",
raw: notice,
cooked: cooked.toString(),
}
: null
)
)
.then(resolve, reject)
.finally(() => this.args.closeModal());
}
}

View File

@ -169,6 +169,7 @@ export default function transformPost(
if (post.notice) { if (post.notice) {
postAtts.notice = post.notice; postAtts.notice = post.notice;
postAtts.noticeCreatedByUser = post.notice_created_by_user;
if (postAtts.notice.type === "returning_user") { if (postAtts.notice.type === "returning_user") {
postAtts.notice.lastPostedAt = new Date(post.notice.last_posted_at); postAtts.notice.lastPostedAt = new Date(post.notice.last_posted_at);
} }

View File

@ -232,7 +232,10 @@ export default class Post extends RestModel {
data[field] = value; data[field] = value;
return ajax(`/posts/${this.id}/${field}`, { type: "PUT", data }) return ajax(`/posts/${this.id}/${field}`, { type: "PUT", data })
.then(() => this.set(field, value)) .then((response) => {
this.set(field, value);
return response;
})
.catch(popupAjaxError); .catch(popupAjaxError);
} }

View File

@ -20,7 +20,11 @@ import {
import { consolePrefix } from "discourse/lib/source-identifier"; import { consolePrefix } from "discourse/lib/source-identifier";
import { transformBasicPost } from "discourse/lib/transform-post"; import { transformBasicPost } from "discourse/lib/transform-post";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import { clipboardCopy, formatUsername } from "discourse/lib/utilities"; import {
clipboardCopy,
escapeExpression,
formatUsername,
} from "discourse/lib/utilities";
import DecoratorHelper from "discourse/widgets/decorator-helper"; import DecoratorHelper from "discourse/widgets/decorator-helper";
import widgetHbs from "discourse/widgets/hbs-compiler"; import widgetHbs from "discourse/widgets/hbs-compiler";
import PostCooked from "discourse/widgets/post-cooked"; import PostCooked from "discourse/widgets/post-cooked";
@ -808,9 +812,28 @@ createWidget("post-notice", {
html(attrs) { html(attrs) {
if (attrs.notice.type === "custom") { if (attrs.notice.type === "custom") {
let createdByHTML = "";
if (attrs.noticeCreatedByUser) {
const createdByName = escapeExpression(
prioritizeNameInUx(attrs.noticeCreatedByUser.name)
? attrs.noticeCreatedByUser.name
: attrs.noticeCreatedByUser.username
);
createdByHTML = i18n("post.notice.custom_created_by", {
userLinkHTML: `<a
class="trigger-user-card"
data-user-card="${attrs.noticeCreatedByUser.username}"
title="${createdByName}"
aria-hidden="false"
role="listitem"
>${createdByName}</a>`,
});
}
return [ return [
iconNode("user-shield"), iconNode("user-shield"),
new RawHtml({ html: `<div>${attrs.notice.cooked}</div>` }), new RawHtml({
html: `<div class="post-notice-message">${attrs.notice.cooked} ${createdByHTML}</div>`,
}),
]; ];
} }

View File

@ -1126,6 +1126,38 @@ module("Integration | Component | Widget | post", function (hooks) {
); );
}); });
test("post notice - custom official notice with created by username", async function (assert) {
this.siteSettings.display_name_on_posts = false;
this.siteSettings.prioritize_username_in_ux = true;
this.set("args", {
notice: {
type: "custom",
cooked: "<p>This is an official notice</p>",
},
noticeCreatedByUser: {
username: "codinghorror",
name: "Jeff",
id: 1,
},
});
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".post-notice.custom").hasText(
"This is an official notice " +
i18n("post.notice.custom_created_by", {
userLinkHTML: "codinghorror",
})
);
assert
.dom(
".post-notice.custom .post-notice-message a.trigger-user-card[data-user-card='codinghorror']"
)
.exists();
});
test("post notice - with name", async function (assert) { test("post notice - with name", async function (assert) {
this.siteSettings.display_name_on_posts = true; this.siteSettings.display_name_on_posts = true;
this.siteSettings.prioritize_username_in_ux = false; this.siteSettings.prioritize_username_in_ux = false;

View File

@ -1432,6 +1432,10 @@ span.mention {
} }
} }
.post-notice-message p {
display: inline;
}
p { p {
margin: 0; margin: 0;
} }

View File

@ -625,10 +625,12 @@ class PostsController < ApplicationController
old_notice = post.custom_fields[Post::NOTICE] old_notice = post.custom_fields[Post::NOTICE]
if params[:notice].present? if params[:notice].present?
cooked_notice = PrettyText.cook(params[:notice], features: { onebox: false })
post.custom_fields[Post::NOTICE] = { post.custom_fields[Post::NOTICE] = {
type: Post.notices[:custom], type: Post.notices[:custom],
raw: params[:notice], raw: params[:notice],
cooked: PrettyText.cook(params[:notice], features: { onebox: false }), cooked: cooked_notice,
created_by_user_id: current_user.id,
} }
else else
post.custom_fields.delete(Post::NOTICE) post.custom_fields.delete(Post::NOTICE)
@ -642,7 +644,7 @@ class PostsController < ApplicationController
new_value: params[:notice], new_value: params[:notice],
) )
render body: nil render json: success_json.merge(cooked_notice:)
end end
def destroy_bookmark def destroy_bookmark

View File

@ -11,6 +11,7 @@ class PostSerializer < BasicPostSerializer
post_actions post_actions
all_post_actions all_post_actions
add_excerpt add_excerpt
notice_created_by_users
] ]
INSTANCE_VARS.each { |v| self.public_send(:attr_accessor, v) } INSTANCE_VARS.each { |v| self.public_send(:attr_accessor, v) }
@ -82,6 +83,7 @@ class PostSerializer < BasicPostSerializer
:action_code_who, :action_code_who,
:action_code_path, :action_code_path,
:notice, :notice,
:notice_created_by_user,
:last_wiki_edit, :last_wiki_edit,
:locked, :locked,
:excerpt, :excerpt,
@ -518,6 +520,19 @@ class PostSerializer < BasicPostSerializer
include_action_code? && action_code_path.present? include_action_code? && action_code_path.present?
end end
def include_notice_created_by_user?
scope.is_staff? && notice.present? && notice_created_by_users.present?
end
def notice_created_by_user
return if notice.blank?
return if notice["type"] != Post.notices[:custom]
return if notice["created_by_user_id"].blank?
found_user = notice_created_by_users&.find { |user| user.id == notice["created_by_user_id"] }
return if !found_user
BasicUserSerializer.new(found_user, root: false).as_json
end
def notice def notice
post_custom_fields[Post::NOTICE] post_custom_fields[Post::NOTICE]
end end

View File

@ -64,11 +64,25 @@ module PostStreamSerializerMixin
serializer.add_raw = true if @options[:include_raw] serializer.add_raw = true if @options[:include_raw]
serializer.topic_view = object serializer.topic_view = object
serializer.notice_created_by_users = post_notice_created_by_users if scope.is_staff?
serializer.as_json serializer.as_json
end end
end end
end end
def post_notice_created_by_users
@post_notice_created_by_users ||=
begin
user_ids =
object
.post_custom_fields
.filter_map { |_, custom_fields| custom_fields.dig(Post::NOTICE, "created_by_user_id") }
.uniq
User.real.where(id: user_ids)
end
end
def theme_modifier_helper def theme_modifier_helper
@theme_modifier_helper ||= ThemeModifierHelper.new(request: scope.request) @theme_modifier_helper ||= ThemeModifierHelper.new(request: scope.request)
end end

View File

@ -3811,6 +3811,7 @@ en:
notice: notice:
new_user: "This is the first time %{user} has posted — lets welcome them to our community!" new_user: "This is the first time %{user} has posted — lets welcome them to our community!"
returning_user: "Its been a while since weve seen %{user} — their last post was %{time}." returning_user: "Its been a while since weve seen %{user} — their last post was %{time}."
custom_created_by: "(added by %{userLinkHTML})"
unread: "Post is unread" unread: "Post is unread"
has_replies: has_replies:
@ -3940,9 +3941,9 @@ en:
delete_topic_confirm_modal_no: "No, keep this topic" delete_topic_confirm_modal_no: "No, keep this topic"
delete_topic_error: "An error occurred while deleting this topic" delete_topic_error: "An error occurred while deleting this topic"
delete_topic: "delete topic" delete_topic: "delete topic"
add_post_notice: "Add Staff Notice…" add_post_notice: "Add Official Notice…"
change_post_notice: "Change Staff Notice…" change_post_notice: "Change Official Notice…"
delete_post_notice: "Delete Staff Notice" delete_post_notice: "Delete Official Notice"
remove_timer: "remove timer" remove_timer: "remove timer"
edit_timer: "edit timer" edit_timer: "edit timer"

View File

@ -595,6 +595,9 @@ RSpec.describe "posts" do
notice: { notice: {
type: :object, type: :object,
}, },
notice_created_by_user: {
type: %i[object null],
},
reviewable_id: { reviewable_id: {
type: %i[integer null], type: %i[integer null],
}, },

View File

@ -2911,6 +2911,7 @@ RSpec.describe PostsController do
"type" => Post.notices[:custom], "type" => Post.notices[:custom],
"raw" => raw_notice, "raw" => raw_notice,
"cooked" => PrettyText.cook(raw_notice, features: { onebox: false }), "cooked" => PrettyText.cook(raw_notice, features: { onebox: false }),
"created_by_user_id" => moderator.id,
) )
expect(UserHistory.where(action: UserHistory.actions[:post_staff_note_create]).count).to eq(1) expect(UserHistory.where(action: UserHistory.actions[:post_staff_note_create]).count).to eq(1)
@ -2944,6 +2945,7 @@ RSpec.describe PostsController do
"type" => Post.notices[:custom], "type" => Post.notices[:custom],
"raw" => raw_notice, "raw" => raw_notice,
"cooked" => PrettyText.cook(raw_notice, features: { onebox: false }), "cooked" => PrettyText.cook(raw_notice, features: { onebox: false }),
"created_by_user_id" => user.id,
) )
put "/posts/#{public_post.id}/notice.json", params: { notice: nil } put "/posts/#{public_post.id}/notice.json", params: { notice: nil }

View File

@ -224,32 +224,102 @@ RSpec.describe PostSerializer do
fab!(:user) { Fabricate(:user, trust_level: 1) } fab!(:user) { Fabricate(:user, trust_level: 1) }
fab!(:user_tl1) { Fabricate(:user, trust_level: 1) } fab!(:user_tl1) { Fabricate(:user, trust_level: 1) }
fab!(:user_tl2) { Fabricate(:user, trust_level: 2) } fab!(:user_tl2) { Fabricate(:user, trust_level: 2) }
fab!(:post) { Fabricate(:post, user: user) }
let(:post) do def json_for_user(user, serializer_opts = {})
post = Fabricate(:post, user: user) serializer = PostSerializer.new(post, scope: Guardian.new(user), root: false)
post.custom_fields[Post::NOTICE] = {
type: Post.notices[:returning_user], if serializer_opts[:notice_created_by_users]
last_posted_at: 1.day.ago, serializer.notice_created_by_users = serializer_opts[:notice_created_by_users]
} end
post.save_custom_fields
post serializer.as_json(serializer_opts)
end end
def json_for_user(user) describe "returning_user notice" do
PostSerializer.new(post, scope: Guardian.new(user), root: false).as_json before do
post.custom_fields[Post::NOTICE] = {
type: Post.notices[:returning_user],
last_posted_at: 1.day.ago,
}
post.save_custom_fields
end
it "is visible for TL2+ users (except poster)" do
expect(json_for_user(nil)[:notice]).to eq(nil)
expect(json_for_user(user)[:notice]).to eq(nil)
SiteSetting.returning_user_notice_tl = 2
expect(json_for_user(user_tl1)[:notice]).to eq(nil)
expect(json_for_user(user_tl2)[:notice][:type]).to eq(Post.notices[:returning_user])
SiteSetting.returning_user_notice_tl = 1
expect(json_for_user(user_tl1)[:notice][:type]).to eq(Post.notices[:returning_user])
expect(json_for_user(user_tl2)[:notice][:type]).to eq(Post.notices[:returning_user])
end
end end
it "is visible for TL2+ users (except poster)" do describe "custom notice" do
expect(json_for_user(nil)[:notice]).to eq(nil) fab!(:moderator)
expect(json_for_user(user)[:notice]).to eq(nil)
SiteSetting.returning_user_notice_tl = 2 before do
expect(json_for_user(user_tl1)[:notice]).to eq(nil) post.custom_fields[Post::NOTICE] = {
expect(json_for_user(user_tl2)[:notice][:type]).to eq(Post.notices[:returning_user]) type: Post.notices[:custom],
raw: "This is a notice",
cooked: "<p>This is a notice</p>",
created_by_user_id: moderator.id,
}
post.save_custom_fields
end
SiteSetting.returning_user_notice_tl = 1 it "displays for all trust levels" do
expect(json_for_user(user_tl1)[:notice][:type]).to eq(Post.notices[:returning_user]) expect(json_for_user(user)[:notice]).to eq(
expect(json_for_user(user_tl2)[:notice][:type]).to eq(Post.notices[:returning_user]) {
cooked: "<p>This is a notice</p>",
created_by_user_id: moderator.id,
raw: "This is a notice",
type: Post.notices[:custom],
}.with_indifferent_access,
)
expect(json_for_user(user_tl1)[:notice]).to eq(
{
cooked: "<p>This is a notice</p>",
created_by_user_id: moderator.id,
raw: "This is a notice",
type: Post.notices[:custom],
}.with_indifferent_access,
)
expect(json_for_user(user_tl2)[:notice]).to eq(
{
cooked: "<p>This is a notice</p>",
created_by_user_id: moderator.id,
raw: "This is a notice",
type: Post.notices[:custom],
}.with_indifferent_access,
)
end
it "only displays the created_by_user for staff" do
expect(
json_for_user(user, notice_created_by_users: [moderator])[:notice_created_by_user],
).to eq(nil)
expect(
json_for_user(user_tl1, notice_created_by_users: [moderator])[:notice_created_by_user],
).to eq(nil)
expect(
json_for_user(user_tl2, notice_created_by_users: [moderator])[:notice_created_by_user],
).to eq(nil)
expect(
json_for_user(moderator, notice_created_by_users: [moderator])[:notice_created_by_user],
).to eq(
{
id: moderator.id,
username: moderator.username,
name: moderator.name,
avatar_template: moderator.avatar_template,
},
)
end
end end
end end

View File

@ -108,7 +108,7 @@ describe "Post menu", type: :system do
expect(topic_page).to have_post_action_button(post2, :admin) expect(topic_page).to have_post_action_button(post2, :admin)
end end
it "displays the admin button when the user can wiki the post / edit staff notices" do it "displays the admin button when the user can wiki the post / edit official notices" do
# display the admin button when the user can wiki # display the admin button when the user can wiki
sign_in(Fabricate(:trust_level_4)) sign_in(Fabricate(:trust_level_4))