FEATURE: Show when a badge has been granted for a post (#29696)

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
Co-authored-by: Jarek Radosz <jradosz@gmail.com>
Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
This commit is contained in:
Gary Pendergast 2024-12-03 13:43:27 +11:00 committed by GitHub
parent 435fbb7408
commit 2513339955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 479 additions and 30 deletions

View File

@ -27,6 +27,7 @@ const FORM_FIELDS = [
"badge_grouping_id",
"trigger",
"badge_type_id",
"show_in_post_header",
];
export default class AdminBadgesShowController extends Controller {
@ -40,8 +41,6 @@ export default class AdminBadgesShowController extends Controller {
@tracked model;
@tracked previewLoading = false;
@tracked selectedGraphicType = null;
@tracked userBadges;
@tracked userBadgesAll;
@cached
get formData() {
@ -80,6 +79,17 @@ export default class AdminBadgesShowController extends Controller {
return this.model.system;
}
@action
postHeaderDescription(data) {
return this.disableBadgeOnPosts(data) && !data.system;
}
@action
disableBadgeOnPosts(data) {
const { listable, show_posts } = data;
return !listable || !show_posts;
}
setup() {
// this is needed because the model doesnt have default values
// Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet.

View File

@ -245,7 +245,7 @@
</field.Menu>
</form.Field>
<form.CheckboxGroup as |group|>
<form.CheckboxGroup @title={{i18n "admin.badges.usage_heading"}} as |group|>
<group.Field
@title={{i18n "admin.badges.allow_title"}}
@showTitle={{false}}
@ -264,7 +264,12 @@
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.CheckboxGroup
@title={{i18n "admin.badges.visibility_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.listable"}}
@showTitle={{false}}
@ -284,6 +289,20 @@
>
<field.Checkbox />
</group.Field>
<group.Field
@title={{i18n "admin.badges.show_in_post_header"}}
@showTitle={{false}}
@name="show_in_post_header"
@disabled={{this.disableBadgeOnPosts data}}
as |field|
>
<field.Checkbox>
{{#if (this.postHeaderDescription data)}}
{{i18n "admin.badges.show_in_post_header_disabled"}}
{{/if}}
</field.Checkbox>
</group.Field>
</form.CheckboxGroup>
</form.Section>

View File

@ -10,6 +10,10 @@ export default class BadgeButton extends Component {
}
}
get showName() {
return this.args.showName ?? true;
}
<template>
<span
title={{this.title}}
@ -20,7 +24,9 @@ export default class BadgeButton extends Component {
...attributes
>
{{iconOrImage @badge}}
<span class="badge-display-name">{{@badge.name}}</span>
{{#if this.showName}}
<span class="badge-display-name">{{@badge.name}}</span>
{{/if}}
{{yield}}
</span>
</template>

View File

@ -15,7 +15,7 @@ export default class UserBadge extends Component {
<template>
<a class="user-card-badge-link" href={{this.badgeUrl}}>
<BadgeButton @badge={{@badge}}>
<BadgeButton @badge={{@badge}} @showName={{@showName}}>
{{#if this.showGrantCount}}
<span class="count">&nbsp;(&times;{{@count}})</span>
{{/if}}

View File

@ -1,5 +1,6 @@
import { isEmpty } from "@ember/utils";
import { userPath } from "discourse/lib/url";
import Badge from "discourse/models/badge";
import getURL from "discourse-common/lib/get-url";
import { i18n } from "discourse-i18n";
@ -37,6 +38,9 @@ export function transformBasicPost(post) {
user_id: post.user_id,
usernameUrl: userPath(post.username),
username: post.username,
badgesGranted: post.badges_granted?.map(
(badge) => Badge.createFromJson(badge)[0]
),
avatar_template: post.avatar_template,
bookmarked: post.bookmarked,
bookmarkReminderAt: post.bookmark_reminder_at,

View File

@ -1,6 +1,8 @@
import { hbs } from "ember-cli-htmlbars";
import { h } from "virtual-dom";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { formatUsername } from "discourse/lib/utilities";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { applyDecorators, createWidget } from "discourse/widgets/widget";
import getURL from "discourse-common/lib/get-url";
import { iconNode } from "discourse-common/lib/icon-library";
@ -139,6 +141,29 @@ export default createWidget("poster-name", {
}
}
if (attrs.badgesGranted) {
const badges = [];
attrs.badgesGranted.forEach((badge) => {
// Alter the badge description to show that the badge was granted for this post.
badge.description = i18n("post.badge_granted_tooltip", {
username: attrs.username,
badge_name: badge.name,
});
const badgeIcon = new RenderGlimmer(
this,
`span.user-badge-button-${badge.slug}`,
hbs`<UserBadge @badge={{@data.badge}} @user={{@data.user}} @showName={{false}} />`,
{
badge,
user: attrs.user,
}
);
badges.push(badgeIcon);
});
nameContents.push(h("span.user-badge-buttons", badges));
}
const afterNameContents =
applyDecorators(this, "after-name", attrs, this.state) || [];

View File

@ -77,4 +77,22 @@ module("Integration | Component | badge-button", function (hooks) {
assert.dom(".user-badge.foo").exists();
});
test("setting showName to false hides the name", async function (assert) {
this.set("badge", { name: "foo" });
await render(
hbs`<BadgeButton @badge={{this.badge}} @showName={{false}} />`
);
assert.dom(".badge-display-name").doesNotExist();
});
test("showName defaults to true", async function (assert) {
this.set("badge", { name: "foo" });
await render(hbs`<BadgeButton @badge={{this.badge}} />`);
assert.dom(".badge-display-name").exists();
});
});

View File

@ -1,6 +1,8 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import Badge from "discourse/models/badge";
import User from "discourse/models/user";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | Widget | poster-name", function (hooks) {
@ -70,4 +72,38 @@ module("Integration | Component | Widget | poster-name", function (hooks) {
assert.dom(".second").doesNotExist();
});
test("renders badges that are passed in", async function (assert) {
this.set("args", {
username: "eviltrout",
usernameUrl: "/u/eviltrout",
user: User.create({
username: "eviltrout",
}),
badgesGranted: [
{ id: 1, icon: "heart", slug: "badge1", name: "Badge One" },
{ id: 2, icon: "target", slug: "badge2", name: "Badge Two" },
].map((badge) => Badge.createFromJson({ badges: [badge] })[0]),
});
await render(
hbs`<MountWidget @widget="poster-name" @args={{this.args}} />`
);
// Check that the custom CSS classes are set
assert.dom("span.user-badge-button-badge1").exists();
assert.dom("span.user-badge-button-badge2").exists();
// Check that the custom titles are set
assert.dom("span.user-badge[title*='Badge One']").exists();
assert.dom("span.user-badge[title*='Badge Two']").exists();
// Check that the badges link to the correct badge page
assert
.dom("a.user-card-badge-link[href='/badges/1/badge1?username=eviltrout']")
.exists();
assert
.dom("a.user-card-badge-link[href='/badges/2/badge2?username=eviltrout']")
.exists();
});
});

View File

@ -259,6 +259,19 @@
margin-right: auto;
}
.user-badge-buttons {
margin-left: 15px;
a {
background: none;
}
.user-badge {
background: none;
border: none;
}
}
.post-infos {
display: flex;
flex: 0 0 auto;

View File

@ -344,27 +344,28 @@ end
#
# Table name: badges
#
# id :integer not null, primary key
# name :string not null
# description :text
# badge_type_id :integer not null
# grant_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# allow_title :boolean default(FALSE), not null
# multiple_grant :boolean default(FALSE), not null
# icon :string default("fa-certificate")
# listable :boolean default(TRUE)
# target_posts :boolean default(FALSE)
# query :text
# enabled :boolean default(TRUE), not null
# auto_revoke :boolean default(TRUE), not null
# badge_grouping_id :integer default(5), not null
# trigger :integer
# show_posts :boolean default(FALSE), not null
# system :boolean default(FALSE), not null
# long_description :text
# image_upload_id :integer
# id :integer not null, primary key
# name :string not null
# description :text
# badge_type_id :integer not null
# grant_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# allow_title :boolean default(FALSE), not null
# multiple_grant :boolean default(FALSE), not null
# icon :string default("fa-certificate")
# listable :boolean default(TRUE)
# target_posts :boolean default(FALSE)
# query :text
# enabled :boolean default(TRUE), not null
# auto_revoke :boolean default(TRUE), not null
# badge_grouping_id :integer default(5), not null
# trigger :integer
# show_posts :boolean default(FALSE), not null
# system :boolean default(FALSE), not null
# show_in_post_header :boolean default(FALSE), not null
# long_description :text
# image_upload_id :integer
#
# Indexes
#

View File

@ -35,6 +35,19 @@ class UserBadge < ActiveRecord::Base
scope :for_enabled_badges,
-> { where("user_badges.badge_id IN (SELECT id FROM badges WHERE enabled)") }
scope :by_post_and_user,
->(posts) do
posts.reduce(UserBadge.none) do |scope, post|
scope.or(UserBadge.where(user_id: post.user_id, post_id: post.id))
end
end
scope :for_post_header_badges,
->(posts) do
by_post_and_user(posts).where(
"user_badges.badge_id IN (SELECT id FROM badges WHERE show_posts AND enabled AND listable AND show_in_post_header)",
)
end
validates :badge_id, presence: true, uniqueness: { scope: :user_id }, if: :single_grant_badge?
validates :user_id, presence: true

View File

@ -16,7 +16,8 @@ class BadgeSerializer < ApplicationSerializer
:long_description,
:slug,
:has_badge,
:manually_grantable?
:manually_grantable?,
:show_in_post_header
has_one :badge_type

View File

@ -38,6 +38,7 @@ class PostSerializer < BasicPostSerializer
:flair_bg_color,
:flair_color,
:flair_group_id,
:badges_granted,
:version,
:can_edit,
:can_delete,
@ -223,6 +224,18 @@ class PostSerializer < BasicPostSerializer
object.user&.flair_group_id
end
def badges_granted
return [] unless SiteSetting.enable_badges && SiteSetting.show_badges_in_post_header
if @topic_view
user_badges = @topic_view.post_user_badges[object.id] || []
else
user_badges = UserBadge.for_post_header_badges([object])
end
user_badges.map { |user_badge| BasicUserBadgeSerializer.new(user_badge, scope: scope).as_json }
end
def link_counts
return @single_post_link_counts if @single_post_link_counts.present?

View File

@ -30,6 +30,7 @@ class WebHookPostSerializer < PostSerializer
flair_color
notice
mentioned_users
badges_granted
].each { |attr| define_method("include_#{attr}?") { false } }
def topic_posts

View File

@ -3816,6 +3816,8 @@ en:
in_reply_to: "Load parent post"
view_all_posts: "View all posts"
badge_granted_tooltip: "%{username} earned the '%{badge_name}' badge for this post!"
errors:
create: "Sorry, there was an error creating your post. Please try again."
edit: "Sorry, there was an error editing your post. Please try again."
@ -7195,9 +7197,13 @@ en:
no_user_badges: "%{name} has not been granted any badges."
no_badges: There are no badges that can be granted.
none_selected: "Select a badge to get started"
usage_heading: Usage
allow_title: Allow badge to be used as a title
multiple_grant: Can be granted multiple times
visibility_heading: Visibility
listable: Show badge on the public badges page
show_in_post_header: Show badge on the post it was granted for
show_in_post_header_disabled: Requires both 'Show badge on the public badges page' and 'Show post granting badge on badge page' to be enabled.
enabled: enabled
disabled: disabled
icon: Icon

View File

@ -1942,6 +1942,7 @@ en:
email_token_valid_hours: "Forgot password / activate account tokens are valid for (n) hours."
enable_badges: "Enable the badge system, which is a form of gamification to reinforce positive user actions. See <a href='https://meta.discourse.org/t/what-are-badges/32540' _target='blank'>What are Badges?</a> on Discourse Meta for more information."
show_badges_in_post_header: "When a user earns a badge for a particular post, display the badge in the post header."
max_favorite_badges: "Maximum number of badges that user can select"
whispers_allowed_groups: "Allow private communication within topics for members of specified groups."
hidden_post_visible_groups: "Allow members of these groups to view hidden posts. Staff users can always view hidden posts."

View File

@ -342,6 +342,9 @@ basic:
enable_badges:
client: true
default: true
show_badges_in_post_header:
client: true
default: true
enable_badge_sql:
client: true
default: false

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class AddPostHeaderToBadge < ActiveRecord::Migration[7.1]
def change
add_column :badges, :show_in_post_header, :boolean, default: false, null: false
end
end

View File

@ -205,6 +205,25 @@ class TopicView
{ users: user_badge_mapping, badges: indexed_badges }
end
def post_user_badges
return [] unless SiteSetting.enable_badges && SiteSetting.show_badges_in_post_header
@post_user_badges ||=
begin
UserBadge
.for_post_header_badges(@posts)
.reduce({}) do |hash, user_badge|
hash[user_badge.post_id] ||= []
hash[user_badge.post_id] << user_badge
hash
end
end
return [] unless @post_user_badges
@post_user_badges
end
def show_read_indicator?
return false if !@user || !topic.private_message?

View File

@ -106,6 +106,9 @@
},
"badge_type_id": {
"type": "integer"
},
"show_in_post_header": {
"type": "boolean"
}
},
"required": [
@ -130,7 +133,8 @@
"auto_revoke",
"show_posts",
"badge_type_id",
"image_upload_id"
"image_upload_id",
"show_in_post_header"
]
}
},

View File

@ -91,6 +91,9 @@
},
"badge_type_id": {
"type": "integer"
},
"show_in_post_header": {
"type": "boolean"
}
},
"required": [
@ -115,7 +118,8 @@
"auto_revoke",
"show_posts",
"badge_type_id",
"image_upload_id"
"image_upload_id",
"show_in_post_header"
]
}
},

View File

@ -106,6 +106,9 @@
},
"badge_type_id": {
"type": "integer"
},
"show_in_post_header": {
"type": "boolean"
}
},
"required": [
@ -130,7 +133,8 @@
"auto_revoke",
"show_posts",
"badge_type_id",
"image_upload_id"
"image_upload_id",
"show_in_post_header"
]
}
},

View File

@ -98,6 +98,10 @@
"null"
]
},
"badges_granted": {
"type": "array",
"items": {}
},
"version": {
"type": "integer"
},

View File

@ -109,6 +109,10 @@
"null"
]
},
"badges_granted": {
"type": "array",
"items": {}
},
"version": {
"type": "integer"
},

View File

@ -438,6 +438,201 @@ RSpec.describe PostSerializer do
end
end
describe "#badges_granted" do
fab!(:user)
fab!(:user2) { Fabricate(:user) }
fab!(:post) { Fabricate(:post, user: user) }
fab!(:post2) { Fabricate(:post, user: user) }
# Create twp badges that have all required flags set to true
fab!(:badge1) do
Badge.create!(
name: "SomeBadge",
badge_type_id: BadgeType::Bronze,
listable: true,
show_posts: true,
show_in_post_header: true,
multiple_grant: true,
)
end
fab!(:ub1) do
UserBadge.create!(
badge_id: badge1.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
fab!(:badge2) do
Badge.create!(
name: "SomeOtherBadge",
badge_type_id: BadgeType::Bronze,
listable: true,
show_posts: true,
show_in_post_header: true,
multiple_grant: true,
)
end
fab!(:ub2) do
UserBadge.create!(
badge_id: badge2.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
# Create a badge that has the show_posts flag set to false
fab!(:badge3) do
Badge.create!(
name: "YetAnotherBadge",
badge_type_id: BadgeType::Bronze,
listable: true,
show_posts: false,
show_in_post_header: true,
)
end
fab!(:ub3) do
UserBadge.create!(
badge_id: badge3.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
# Re-use our first badge, but on a different post
fab!(:ub4) do
UserBadge.create!(
badge_id: badge1.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post2.id,
)
end
# Now re-use our first badge, but on a different user
fab!(:ub5) do
UserBadge.create!(
badge_id: badge1.id,
user: user2,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
# Create a badge that has the listable flag set to false
fab!(:badge4) do
Badge.create!(
name: "WeirdBadge",
badge_type_id: BadgeType::Bronze,
listable: false,
show_posts: true,
show_in_post_header: true,
)
end
fab!(:ub6) do
UserBadge.create!(
badge_id: badge4.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
# Create a badge that has the show_in_post_header flag set to false
fab!(:badge5) do
Badge.create!(
name: "StrangeBadge",
badge_type_id: BadgeType::Bronze,
listable: true,
show_posts: true,
show_in_post_header: false,
)
end
fab!(:ub7) do
UserBadge.create!(
badge_id: badge5.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
let(:serializer) { described_class.new(post, scope: Guardian.new(user), root: false) }
it "doesn't include badges when `enable_badges` site setting is disabled" do
SiteSetting.enable_badges = false
expect(serializer.as_json[:badges_granted]).to eq([])
end
it "doesn't include badges when `show_badges_in_post_header` site setting is disabled" do
SiteSetting.enable_badges = true
SiteSetting.show_badges_in_post_header = false
expect(serializer.as_json[:badges_granted]).to eq([])
end
context "when `enable_badges` and `show_badges_in_post_header` site settings are enabled" do
before do
SiteSetting.enable_badges = true
SiteSetting.show_badges_in_post_header = true
end
it "includes badges that were granted for this user on this post" do
json = serializer.as_json
expect(json[:badges_granted].length).to eq(2)
expect(json[:badges_granted].map { |b| b[:badges][0][:id] }).to contain_exactly(
ub1.badge_id,
ub2.badge_id,
)
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).to contain_exactly(
ub1.id,
ub2.id,
)
end
it "does not return a user badge that has the show_posts flag set to false" do
json = serializer.as_json
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).not_to include(ub3.id)
end
it "does not return a user badge that was not granted for this post" do
json = serializer.as_json
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).not_to include(ub4.id)
end
it "does not return a user badge that was granted for a different user" do
json = serializer.as_json
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).not_to include(ub5.id)
end
it "does not return a user badge that has the listable flag set to false" do
json = serializer.as_json
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).not_to include(ub6.id)
end
it "does not return a user badge that has the show_in_post_header flag set to false" do
json = serializer.as_json
expect(json[:badges_granted].map { |b| b[:basic_user_badge][:id] }).not_to include(ub7.id)
end
end
end
def serialized_post(u)
s = PostSerializer.new(post, scope: Guardian.new(u), root: false)
s.add_raw = true

View File

@ -33,4 +33,43 @@ describe "Granting Badges", type: :system do
expect(granted_badge.post_id).to eq post.id
end
end
context "when granting a badge that shows in the post header" do
fab!(:user)
fab!(:post) { Fabricate(:post, user: user) }
let(:topic_page) { PageObjects::Pages::Topic.new }
fab!(:badge) do
Fabricate(
:manually_grantable_badge,
name: "SomeBadge",
listable: true,
show_posts: true,
show_in_post_header: true,
)
end
fab!(:user_badge) do
UserBadge.create!(
badge_id: badge.id,
user: user,
granted_by: Discourse.system_user,
granted_at: Time.now,
post_id: post.id,
)
end
it "shows badge in post header" do
topic_page.visit_topic(post.topic)
expect(topic_page.post_by_number(post).find(".user-badge-buttons")).to have_css(
".user-badge-button-somebadge",
)
end
it "doesn't show badge in post header when `show_badges_in_post_header` site setting is disabled" do
SiteSetting.show_badges_in_post_header = false
topic_page.visit_topic(post.topic)
expect(topic_page.post_by_number(post)).to_not have_css(".user-badge-buttons")
end
end
end