Skip to content

Commit

Permalink
FEATURE: lets users favorite 2 badges to show on user-card (#13151)
Browse files Browse the repository at this point in the history
  • Loading branch information
jjaffeux committed Jun 1, 2021
1 parent c5174e6 commit 1cd0424
Show file tree
Hide file tree
Showing 17 changed files with 187 additions and 11 deletions.
5 changes: 5 additions & 0 deletions app/assets/javascripts/discourse/app/components/badge-card.js
Expand Up @@ -31,4 +31,9 @@ export default Component.extend({
}
return sanitize(description);
},

@discourseComputed("badge.id")
showFavorite(badgeId) {
return ![1, 2, 3, 4].includes(badgeId);
},
});
17 changes: 15 additions & 2 deletions app/assets/javascripts/discourse/app/controllers/user-badges.js
@@ -1,14 +1,27 @@
import Controller, { inject as controller } from "@ember/controller";
import { alias, sort } from "@ember/object/computed";
import { action, computed } from "@ember/object";
import { alias, filterBy, sort } from "@ember/object/computed";

export default Controller.extend({
user: controller(),
username: alias("user.model.username_lower"),
sortedBadges: sort("model", "badgeSortOrder"),
favoriteBadges: filterBy("model", "is_favorite", true),
canFavoriteMoreBadges: computed(
"favoriteBadges.length",
"model.meta.max_favorites",
function () {
return this.favoriteBadges.length < this.model.meta.max_favorites;
}
),

init() {
this._super(...arguments);

this.badgeSortOrder = ["badge.badge_type.sort_order:desc", "badge.name"];
},

@action
favorite(badge) {
return badge.favorite();
},
});
13 changes: 13 additions & 0 deletions app/assets/javascripts/discourse/app/models/user-badge.js
Expand Up @@ -4,8 +4,11 @@ import { Promise } from "rsvp";
import Topic from "discourse/models/topic";
import User from "discourse/models/user";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseComputed from "discourse-common/utils/decorators";

const DEFAULT_USER_BADGES_META = { max_favorites: 2 };

const UserBadge = EmberObject.extend({
@discourseComputed
postUrl: function () {
Expand All @@ -19,6 +22,15 @@ const UserBadge = EmberObject.extend({
type: "DELETE",
});
},

favorite() {
return ajax(`/user_badges/${this.id}/toggle_favorite`, { type: "PUT" })
.then((json) => {
this.set("is_favorite", json.user_badge.is_favorite);
return this;
})
.catch(popupAjaxError);
},
});

UserBadge.reopenClass({
Expand Down Expand Up @@ -86,6 +98,7 @@ UserBadge.reopenClass({
userBadges.grant_count = json.user_badge_info.grant_count;
userBadges.username = json.user_badge_info.username;
}
userBadges.meta = json.meta || DEFAULT_USER_BADGES_META;
return userBadges;
}
},
Expand Down
Expand Up @@ -4,7 +4,26 @@
{{#if badge.has_badge}}
<a href={{url}} class="check-display status-checked">{{d-icon "check"}}</a>
{{/if}}
<div class="badge-contents">

{{#if canFavorite}}
{{#if isFavorite}}
{{d-button
icon="star"
class="favorite-btn"
action=onFavoriteClick
}}
{{else}}
{{d-button
icon="far-star"
class="favorite-btn"
action=onFavoriteClick
title=(if canFavoriteMoreBadges "badges.favorite_max_not_reached" "badges.favorite_max_reached")
disabled=(not canFavoriteMoreBadges)
}}
{{/if}}
{{/if}}

<div class="badge-contents" >
<div class="badge-icon {{badge.badgeTypeClassName}}">
<a href={{url}}>{{icon-or-image badge}}</a>
</div>
Expand Down
@@ -1,9 +1,16 @@
{{#d-section pageClass="user-badges" class="user-content user-badges-list"}}
<p class="favorite-count">
{{i18n "badges.favorite_count" count=this.favoriteBadges.length max=model.meta.max_favorites}}
</p>
{{#each sortedBadges as |ub|}}
{{badge-card
badge=ub.badge
count=ub.count
canFavorite=ub.can_favorite
isFavorite=ub.is_favorite
username=username
canFavoriteMoreBadges=canFavoriteMoreBadges
onFavoriteClick=(action "favorite" ub)
filterUser="true"}}
{{/each}}
{{/d-section}}
Expand Up @@ -576,6 +576,9 @@ export function applyDefaultHandlers(pretender) {
response(200, fixturesByUrl["/user_badges"])
);
pretender.delete("/user_badges/:badge_id", success);
pretender.put("/user_badges/:id/toggle_favorite", () =>
response(200, { user_badge: { is_favorite: true } })
);

pretender.post("/posts", function (request) {
const data = parsePostData(request.requestBody);
Expand Down
Expand Up @@ -57,4 +57,12 @@ module("Unit | Model | user-badge", function () {
const userBadge = UserBadge.create({ id: 1 });
await userBadge.revoke();
});

test("favorite", async function (assert) {
const userBadge = UserBadge.create({ id: 1 });
assert.notOk(userBadge.is_favorite);

await userBadge.favorite();
assert.ok(userBadge.is_favorite);
});
});
6 changes: 6 additions & 0 deletions app/assets/stylesheets/common/base/user-badges.scss
Expand Up @@ -143,6 +143,12 @@
font-size: $font-up-2;
}

.favorite-btn {
position: absolute;
right: 0;
bottom: 0;
}

.badge-contents {
display: flex;
min-height: 128px;
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/desktop/user.scss
Expand Up @@ -60,6 +60,10 @@
flex-wrap: wrap;
}

&.user-badges-list .favorite-count {
flex: 100%;
}

.btn.right {
float: right;
}
Expand Down
37 changes: 33 additions & 4 deletions app/controllers/user_badges_controller.rb
@@ -1,13 +1,16 @@
# frozen_string_literal: true

class UserBadgesController < ApplicationController
MAX_FAVORITES = 2
MAX_BADGES = 96 # This was limited in PR#2360 to make it divisible by 8

before_action :ensure_badges_enabled

def index
params.permit [:granted_before, :offset, :username]

badge = fetch_badge_from_params
user_badges = badge.user_badges.order('granted_at DESC, id DESC').limit(96)
user_badges = badge.user_badges.order('granted_at DESC, id DESC').limit(MAX_BADGES)
user_badges = user_badges.includes(:user, :granted_by, badge: :badge_type, post: :topic, user: :primary_group)

grant_count = nil
Expand Down Expand Up @@ -37,15 +40,19 @@ def username
user_badges = user.user_badges

if params[:grouped]
user_badges = user_badges.group(:badge_id)
.select(UserBadge.attribute_names.map { |x| "MAX(#{x}) AS #{x}" }, 'COUNT(*) AS "count"')
user_badges = user_badges.group(:badge_id).select_for_grouping
end

user_badges = user_badges.includes(badge: [:badge_grouping, :badge_type, :image_upload])
.includes(post: :topic)
.includes(:granted_by)

render_serialized(user_badges, DetailedUserBadgeSerializer, root: :user_badges)
render_serialized(
user_badges,
DetailedUserBadgeSerializer,
root: :user_badges,
meta: { max_favorites: MAX_FAVORITES },
)
end

def create
Expand Down Expand Up @@ -91,6 +98,24 @@ def destroy
render json: success_json
end

def toggle_favorite
params.require(:user_badge_id)
user_badge = UserBadge.find(params[:user_badge_id])
user_badges = user_badge.user.user_badges

unless can_favorite_badge?(user_badge)
return render json: failed_json, status: 403
end

if !user_badge.is_favorite && user_badges.where(is_favorite: true).count >= MAX_FAVORITES
return render json: failed_json, status: 400
end

user_badge.toggle!(:is_favorite)
UserBadge.update_featured_ranks!(user_badge.user_id)
render_serialized(user_badge, DetailedUserBadgeSerializer, root: :user_badge)
end

private

# Get the badge from either the badge name or id specified in the params.
Expand All @@ -114,6 +139,10 @@ def can_assign_badge_to_user?(user)
master_api_call || guardian.can_grant_badges?(user)
end

def can_favorite_badge?(user_badge)
current_user == user_badge.user && !(1..4).include?(user_badge.badge_id)
end

def ensure_badges_enabled
raise Discourse::NotFound unless SiteSetting.enable_badges?
end
Expand Down
19 changes: 17 additions & 2 deletions app/models/user_badge.rb
Expand Up @@ -9,12 +9,24 @@ class UserBadge < ActiveRecord::Base

scope :grouped_with_count, -> {
group(:badge_id, :user_id)
.select(UserBadge.attribute_names.map { |x| "MAX(user_badges.#{x}) AS #{x}" },
'COUNT(*) AS "count"')
.select_for_grouping
.order('MAX(featured_rank) ASC')
.includes(:user, :granted_by, { badge: :badge_type }, post: :topic)
}

scope :select_for_grouping, -> {
select(
UserBadge.attribute_names.map do |name|
if name == 'is_favorite'
"BOOL_OR(user_badges.#{name}) AS is_favorite"
else
"MAX(user_badges.#{name}) AS #{name}"
end
end,
'COUNT(*) AS "count"'
)
}

scope :for_enabled_badges, -> { where('user_badges.badge_id IN (SELECT id FROM badges WHERE enabled)') }

validates :badge_id,
Expand Down Expand Up @@ -62,6 +74,7 @@ def self.update_featured_ranks!(user_id = nil)
PARTITION BY user_badges.user_id -- Do a separate rank for each user
ORDER BY BOOL_OR(badges.enabled) DESC, -- Disabled badges last
MAX(featured_tl_badge.user_id) NULLS LAST, -- Best tl badge first
BOOL_OR(user_badges.is_favorite) DESC NULLS LAST, -- Favorite badges next
CASE WHEN user_badges.badge_id IN (1,2,3,4) THEN 1 ELSE 0 END ASC, -- Non-featured tl badges last
MAX(badges.badge_type_id) ASC,
MAX(badges.grant_count) ASC,
Expand Down Expand Up @@ -102,11 +115,13 @@ def single_grant_badge?
# seq :integer default(0), not null
# featured_rank :integer
# created_at :datetime not null
# is_favorite :boolean
#
# Indexes
#
# index_user_badges_on_badge_id_and_user_id (badge_id,user_id)
# index_user_badges_on_badge_id_and_user_id_and_post_id (badge_id,user_id,post_id) UNIQUE WHERE (post_id IS NOT NULL)
# index_user_badges_on_badge_id_and_user_id_and_seq (badge_id,user_id,seq) UNIQUE WHERE (post_id IS NULL)
# index_user_badges_on_user_id (user_id)
# index_user_badges_on_is_favorite (is_favorite)
#
6 changes: 5 additions & 1 deletion app/serializers/detailed_user_badge_serializer.rb
Expand Up @@ -3,7 +3,7 @@
class DetailedUserBadgeSerializer < BasicUserBadgeSerializer
has_one :granted_by, serializer: UserBadgeSerializer::UserSerializer

attributes :post_number, :topic_id, :topic_title
attributes :post_number, :topic_id, :topic_title, :is_favorite, :can_favorite

def include_post_number?
object.post
Expand All @@ -24,4 +24,8 @@ def topic_title
object.post.topic.title if object.post && object.post.topic
end

def can_favorite
(scope.current_user.present? && object.user_id == scope.current_user.id) &&
!(1..4).include?(object.badge_id)
end
end
3 changes: 3 additions & 0 deletions config/locales/client.en.yml
Expand Up @@ -3617,6 +3617,9 @@ en:
name: Other
posting:
name: Posting
favorite_max_reached: "You can’t favorite more badges."
favorite_max_not_reached: "Mark this badge as favorite"
favorite_count: "%{count}/%{max} badges marked as favorite"

tagging:
all_tags: "All Tags"
Expand Down
4 changes: 3 additions & 1 deletion config/routes.rb
Expand Up @@ -671,7 +671,9 @@

resources :badges, only: [:index]
get "/badges/:id(/:slug)" => "badges#show", constraints: { format: /(json|html|rss)/ }
resources :user_badges, only: [:index, :create, :destroy]
resources :user_badges, only: [:index, :create, :destroy] do
put "toggle_favorite" => "user_badges#toggle_favorite", constraints: { format: :json }
end

get '/c', to: redirect(relative_url_root + 'categories')

Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20210218144656_add_is_favorite_to_user_badge.rb
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddIsFavoriteToUserBadge < ActiveRecord::Migration[6.0]
def change
add_column :user_badges, :is_favorite, :boolean
end
end
1 change: 1 addition & 0 deletions lib/svg_sprite/svg_sprite.rb
Expand Up @@ -113,6 +113,7 @@ module SvgSprite
"far-moon",
"far-smile",
"far-square",
"far-star",
"far-sun",
"far-thumbs-down",
"far-thumbs-up",
Expand Down
37 changes: 37 additions & 0 deletions spec/requests/user_badges_controller_spec.rb
Expand Up @@ -267,4 +267,41 @@
expect(events).to include(:user_badge_removed)
end
end

context "favorite" do
let!(:user_badge) { UserBadge.create(badge: badge, user: user, granted_by: Discourse.system_user, granted_at: Time.now) }

it "checks that the user is authorized to favorite the badge" do
sign_in(Fabricate(:admin))
put "/user_badges/#{user_badge.id}/toggle_favorite.json"
expect(response.status).to eq(403)
end

it "checks that the user has less than two favorited badges" do
sign_in(user)
UserBadge.create(badge: Fabricate(:badge), user: user, granted_by: Discourse.system_user, granted_at: Time.now, is_favorite: true)
UserBadge.create(badge: Fabricate(:badge), user: user, granted_by: Discourse.system_user, granted_at: Time.now, is_favorite: true)
put "/user_badges/#{user_badge.id}/toggle_favorite.json"
expect(response.status).to eq(400)
end

it "favorites a badge" do
sign_in(user)
put "/user_badges/#{user_badge.id}/toggle_favorite.json"
expect(response.status).to eq(200)

user_badge = UserBadge.find_by(user: user, badge: badge)
expect(user_badge.is_favorite).to be true
end

it "unfavorites a badge" do
sign_in(user)
user_badge.toggle!(:is_favorite)
put "/user_badges/#{user_badge.id}/toggle_favorite.json"
expect(response.status).to eq(200)

user_badge = UserBadge.find_by(user: user, badge: badge)
expect(user_badge.is_favorite).to be false
end
end
end

0 comments on commit 1cd0424

Please sign in to comment.