diff --git a/app/assets/javascripts/discourse/app/components/badge-card.js b/app/assets/javascripts/discourse/app/components/badge-card.js index 6e2a862c382..d1da83e296e 100644 --- a/app/assets/javascripts/discourse/app/components/badge-card.js +++ b/app/assets/javascripts/discourse/app/components/badge-card.js @@ -31,4 +31,9 @@ export default Component.extend({ } return sanitize(description); }, + + @discourseComputed("badge.id") + showFavorite(badgeId) { + return ![1, 2, 3, 4].includes(badgeId); + }, }); diff --git a/app/assets/javascripts/discourse/app/controllers/user-badges.js b/app/assets/javascripts/discourse/app/controllers/user-badges.js index a4d55048f92..8634e19e4dd 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-badges.js +++ b/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(); + }, }); diff --git a/app/assets/javascripts/discourse/app/models/user-badge.js b/app/assets/javascripts/discourse/app/models/user-badge.js index 0aaff3f561a..a4c090354fb 100644 --- a/app/assets/javascripts/discourse/app/models/user-badge.js +++ b/app/assets/javascripts/discourse/app/models/user-badge.js @@ -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 () { @@ -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({ @@ -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; } }, diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs index 415e4738c00..fc055308439 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs @@ -4,7 +4,26 @@ {{#if badge.has_badge}} {{d-icon "check"}} {{/if}} -
+ {{i18n "badges.favorite_count" count=this.favoriteBadges.length max=model.meta.max_favorites}} +
{{#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}} diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 7cbea9ca99d..339872180a0 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -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); diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js index 28257789e26..70e30733e29 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js @@ -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); + }); }); diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index 09cce815b8d..cbb52c5a75d 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -143,6 +143,12 @@ font-size: $font-up-2; } + .favorite-btn { + position: absolute; + right: 0; + bottom: 0; + } + .badge-contents { display: flex; min-height: 128px; diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index b1dd3062d41..9c9d2169444 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -60,6 +60,10 @@ flex-wrap: wrap; } + &.user-badges-list .favorite-count { + flex: 100%; + } + .btn.right { float: right; } diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index 05d0a693011..1b13f0c6e00 100644 --- a/app/controllers/user_badges_controller.rb +++ b/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 @@ -37,15 +40,19 @@ class UserBadgesController < ApplicationController 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 @@ -91,6 +98,24 @@ class UserBadgesController < ApplicationController 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. @@ -114,6 +139,10 @@ class UserBadgesController < ApplicationController 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 diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb index 082f645c719..114ce19176d 100644 --- a/app/models/user_badge.rb +++ b/app/models/user_badge.rb @@ -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, @@ -62,6 +74,7 @@ class UserBadge < ActiveRecord::Base 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, @@ -102,6 +115,7 @@ end # seq :integer default(0), not null # featured_rank :integer # created_at :datetime not null +# is_favorite :boolean # # Indexes # @@ -109,4 +123,5 @@ end # 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) # diff --git a/app/serializers/detailed_user_badge_serializer.rb b/app/serializers/detailed_user_badge_serializer.rb index ec84bac004f..8d3649bd6ee 100644 --- a/app/serializers/detailed_user_badge_serializer.rb +++ b/app/serializers/detailed_user_badge_serializer.rb @@ -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 @@ -24,4 +24,8 @@ class DetailedUserBadgeSerializer < BasicUserBadgeSerializer 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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6e7dd772d4b..d9930d2b586 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 88f8ad353d2..f1f1f99ed62 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -671,7 +671,9 @@ Discourse::Application.routes.draw do 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') diff --git a/db/migrate/20210218144656_add_is_favorite_to_user_badge.rb b/db/migrate/20210218144656_add_is_favorite_to_user_badge.rb new file mode 100644 index 00000000000..f885fa70be4 --- /dev/null +++ b/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 diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 49343298d99..bf5aae7ad55 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -113,6 +113,7 @@ module SvgSprite "far-moon", "far-smile", "far-square", + "far-star", "far-sun", "far-thumbs-down", "far-thumbs-up", diff --git a/spec/requests/user_badges_controller_spec.rb b/spec/requests/user_badges_controller_spec.rb index 14c47d68b87..26b55d70fa8 100644 --- a/spec/requests/user_badges_controller_spec.rb +++ b/spec/requests/user_badges_controller_spec.rb @@ -267,4 +267,41 @@ describe UserBadgesController do 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