FEATURE: Featured topic for user profile & card (#8461)

This commit is contained in:
Mark VanLandingham 2019-12-09 11:15:47 -08:00 committed by GitHub
parent b5236591e9
commit 14cb386f1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 418 additions and 95 deletions

View File

@ -1,5 +1,6 @@
import discourseComputed from "discourse-common/utils/decorators";
import { alias, or, and } from "@ember/object/computed";
import { propertyEqual } from "discourse/lib/computed";
import Component from "@ember/component";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
@ -9,6 +10,11 @@ export default Component.extend({
// Allow us to extend it
layoutName: "components/topic-footer-buttons",
topicFeaturedOnProfile: propertyEqual(
"topic.id",
"currentUser.featured_topic.id"
),
@discourseComputed("topic.isPrivateMessage")
canArchive(isPM) {
return this.siteSettings.enable_personal_messages && isPM;
@ -58,5 +64,19 @@ export default Component.extend({
@discourseComputed("topic.message_archived")
archiveLabel: archived =>
archived ? "topic.move_to_inbox.title" : "topic.archive_message.title"
archived ? "topic.move_to_inbox.title" : "topic.archive_message.title",
@discourseComputed(
"topic.user_id",
"topic.isPrivateMessage",
"topic.category.read_restricted"
)
showToggleFeatureOnProfileButton(userId, isPm, restricted) {
return (
this.siteSettings.allow_featured_topic_on_user_profiles &&
userId === this.currentUser.get("id") &&
!restricted &&
!isPm
);
}
});

View File

@ -50,6 +50,11 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
// If inside a topic
topicPostCount: null,
showFeaturedTopic: and(
"user.featured_topic",
"siteSettings.allow_featured_topic_on_user_profiles"
),
@discourseComputed("user.staff")
staff: isStaff => (isStaff ? "staff" : ""),

View File

@ -679,6 +679,19 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
toggleFeaturedOnProfile() {
if (!this.currentUser) return;
if (
this.currentUser.featured_topic &&
this.currentUser.featured_topic.id !== this.model.id
) {
bootbox.confirm(I18n.t("topic.remove_from_profile.warning"), result => {
if (result) return this._performToggleFeaturedOnProfile();
});
} else return this._performToggleFeaturedOnProfile();
},
jumpToIndex(index) {
this._jumpToIndex(index);
},
@ -1070,6 +1083,10 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
_performToggleFeaturedOnProfile() {
this.model.toggleFeaturedOnProfile(this.currentUser).catch(popupAjaxError);
},
_jumpToIndex(index) {
const postStream = this.get("model.postStream");

View File

@ -61,6 +61,11 @@ export default Controller.extend(CanCheckEmails, {
"hasReceivedWarnings"
),
showFeaturedTopic: and(
"model.featured_topic",
"siteSettings.allow_featured_topic_on_user_profiles"
),
@discourseComputed("model.suspended", "currentUser.staff")
isNotSuspendedOrIsStaff(suspended, isStaff) {
return !suspended || isStaff;

View File

@ -166,5 +166,34 @@ export default {
return this.site.mobileView;
}
});
registerTopicFooterButton({
dependentKeys: ["currentUser.featured_topic"],
id: "toggle-feature-on-profile",
icon: "id-card",
priority: 300,
label() {
return this.topicFeaturedOnProfile
? "topic.remove_from_profile.title"
: "topic.feature_on_profile.title";
},
title() {
return this.topicFeaturedOnProfile
? "topic.remove_from_profile.help"
: "topic.feature_on_profile.help";
},
classNames() {
return this.topicFeaturedOnProfile
? ["feature-on-profile", "featured-on-profile"]
: ["feature-on-profile"];
},
action: "toggleFeaturedOnProfile",
displayed() {
return this.showToggleFeatureOnProfileButton;
},
dropdown() {
return this.site.mobileView;
}
});
}
};

View File

@ -444,6 +444,21 @@ const Topic = RestModel.extend({
});
},
toggleFeaturedOnProfile(user) {
const removing = user.get("featured_topic.id") === this.id;
const path = removing ? "clear-featured-topic" : "feature-topic";
return ajax(`/u/${user.username}/${path}`, {
type: "PUT",
data: { topic_id: this.id }
})
.then(() => {
const featuredTopic = removing ? null : this;
user.set("featured_topic", featuredTopic);
return;
})
.catch(popupAjaxError);
},
createGroupInvite(group) {
return ajax(`/t/${this.id}/invite-group`, {
type: "POST",

View File

@ -10,13 +10,13 @@
<div class="card-row second-row">
<div class="animated-placeholder placeholder-animation"></div>
</div>
<div class="card-row third-row">
<div class="card-row">
<div class="animated-placeholder placeholder-animation"></div>
</div>
<div class="card-row fourth-row">
<div class="card-row">
<div class="animated-placeholder placeholder-animation"></div>
</div>
<div class="card-row sixth-row">
<div class="card-row">
<div class="animated-placeholder placeholder-animation"></div>
</div>
{{else}}
@ -140,8 +140,17 @@
</div>
{{/if}}
{{#if showFeaturedTopic}}
<div class="card-row">
<div class="featured-topic">
<span class="desc">{{i18n 'user.featured_topic'}}</span>
{{#link-to "topic" user.featured_topic.slug user.featured_topic.id }}{{user.featured_topic.fancy_title}}{{/link-to}}
</div>
</div>
{{/if}}
{{#if hasLocationOrWebsite}}
<div class="card-row third-row">
<div class="card-row">
<div class="location-and-website">
{{#if user.location}}
<span class='location'>{{d-icon "map-marker-alt"}}
@ -163,7 +172,7 @@
</div>
{{/if}}
<div class="card-row fourth-row">
<div class="card-row">
{{#unless user.profile_hidden}}
<div class="metadata">
{{#if user.last_posted_at}}
@ -203,7 +212,7 @@
</div>
{{#if publicUserFields}}
<div class="card-row fifth-row">
<div class="card-row">
<div class="public-user-fields">
{{#each publicUserFields as |uf|}}
{{#if uf.value}}
@ -220,7 +229,7 @@
{{plugin-outlet name="user-card-before-badges" args=(hash user=user)}}
{{#if showBadges}}
<div class="card-row sixth-row">
<div class="card-row">
{{#if user.featured_user_badges}}
<div class="badge-section">
{{#each user.featured_user_badges as |ub|}}

View File

@ -57,6 +57,18 @@
</div>
{{/if}}
{{#if model.featured_topic}}
<div class="control-group">
<label class="control-label">{{i18n 'user.featured_topic'}}</label>
<label class="control-label">
{{#link-to "topic" model.featured_topic.slug model.featured_topic.id}}{{model.featured_topic.fancy_title}}{{/link-to}}
</label>
<div class='instructions'>
{{i18n 'user.change_featured_topic.instructions'}}
</div>
</div>
{{/if}}
{{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}}
{{plugin-outlet name="user-custom-preferences" args=(hash model=model)}}

View File

@ -313,7 +313,8 @@
toggleArchiveMessage=(action "toggleArchiveMessage")
editFirstPost=(action "editFirstPost")
deferTopic=(action "deferTopic")
replyToPost=(action "replyToPost")}}
replyToPost=(action "replyToPost")
toggleFeaturedOnProfile=(action "toggleFeaturedOnProfile")}}
{{else}}
<div id="topic-footer-buttons">
{{d-button icon="reply" class="btn-primary pull-right" action=(route-action "showLogin") label="topic.reply.title"}}

View File

@ -92,6 +92,14 @@
{{/if}}
{{plugin-outlet name="user-post-names" args=(hash model=model)}}
</div>
{{#if showFeaturedTopic}}
<h3 class="featured-topic">
<span>{{i18n 'user.featured_topic'}}</span>
{{#link-to "topic" model.featured_topic.slug model.featured_topic.id}}{{model.featured_topic.fancy_title}}{{/link-to}}
</h3>
{{/if}}
<h3 class="location-and-website">
{{#if model.location}}<div class="user-profile-location">{{d-icon "map-marker-alt"}} {{model.location}}</div>{{/if}}
{{#if model.website_name}}

View File

@ -181,8 +181,18 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards
margin-top: 0.5em;
}
}
// featured topic
.featured-topic {
.desc {
color: $primary-high;
}
a {
color: $primary;
text-decoration: underline;
}
}
// location and website
.third-row {
.location-and-website {
display: flex;
flex-wrap: wrap;
@ -209,15 +219,12 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards
text-decoration: underline;
}
}
}
// custom user fields
.fifth-row {
.public-user-fields {
margin: 0;
}
}
// badges
.sixth-row {
.badge-section {
display: flex;
align-items: flex-start;
@ -234,7 +241,6 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards
}
}
}
}
// styles for group cards only
#group-card {

View File

@ -37,7 +37,6 @@
// styles for user cards only
#user-card {
// badges
.sixth-row {
.badge-section {
.user-badge {
display: block;
@ -49,4 +48,3 @@
}
}
}
}

View File

@ -451,6 +451,9 @@ nav.post-controls {
.bookmark.bookmarked .d-icon-bookmark {
color: $tertiary;
}
.feature-on-profile.featured-on-profile .d-icon-id-card {
color: $tertiary;
}
}
#topic-footer-button {

View File

@ -5,5 +5,10 @@
color: $tertiary;
}
}
&.featured-on-profile {
.d-icon {
color: $tertiary;
}
}
}
}

View File

@ -50,7 +50,6 @@ $avatar_width: 120px;
// styles for user cards only
#user-card {
// badges
.sixth-row {
.badge-section {
flex-wrap: wrap;
> span {
@ -75,7 +74,6 @@ $avatar_width: 120px;
}
}
}
}
// mobile card cloak
.card-cloak {

View File

@ -11,13 +11,14 @@ class UsersController < ApplicationController
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
:update_second_factor, :create_second_factor_backup, :select_avatar,
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
:create_second_factor_security_key
:create_second_factor_security_key, :feature_topic, :clear_featured_topic
]
skip_before_action :check_xhr, only: [
:show, :badges, :password_reset, :update, :account_created,
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
:feature_topic, :clear_featured_topic
]
before_action :second_factor_check_confirmed_password, only: [
@ -1403,6 +1404,22 @@ class UsersController < ApplicationController
render json: success_json
end
def feature_topic
user = fetch_user_from_params
topic = Topic.find(params[:topic_id].to_i)
raise Discourse::InvalidAccess.new unless topic && guardian.can_feature_topic?(user, topic)
user.user_profile.update(featured_topic_id: topic.id)
render json: success_json
end
def clear_featured_topic
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
user.user_profile.update(featured_topic_id: nil)
render json: success_json
end
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
@ -1457,7 +1474,8 @@ class UsersController < ApplicationController
:dismissed_banner_key,
:profile_background_upload_url,
:card_background_upload_url,
:primary_group_id
:primary_group_id,
:featured_topic_id
]
editable_custom_fields = User.editable_user_custom_fields(by_staff: current_user.try(:staff?))
@ -1532,4 +1550,5 @@ class UsersController < ApplicationController
challenge: secure_session["staged-webauthn-challenge-#{user.id}"]
}
end
end

View File

@ -127,6 +127,7 @@ class Topic < ActiveRecord::Base
has_many :invites, through: :topic_invites, source: :invite
has_many :topic_timers, dependent: :destroy
has_many :reviewables
has_many :user_profiles
has_one :user_warning
has_one :first_post, -> { where post_number: 1 }, class_name: 'Post'
@ -238,6 +239,8 @@ class Topic < ActiveRecord::Base
after_update do
if saved_changes[:category_id] && self.tags.present?
CategoryTagStat.topic_moved(self, *saved_changes[:category_id])
elsif saved_changes[:category_id] && self.category&.read_restricted?
UserProfile.remove_featured_topic_from_all_profiles(self)
end
end

View File

@ -50,6 +50,7 @@ class TopicConverter
add_allowed_users
update_post_uploads_secure_status
UserProfile.remove_featured_topic_from_all_profiles(@topic)
Jobs.enqueue(:topic_action_converter, topic_id: @topic.id)
Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id)

View File

@ -10,6 +10,7 @@ class UserProfile < ActiveRecord::Base
belongs_to :card_background_upload, class_name: "Upload"
belongs_to :profile_background_upload, class_name: "Upload"
belongs_to :granted_title_badge, class_name: "Badge"
belongs_to :featured_topic, class_name: 'Topic'
validates :bio_raw, length: { maximum: 3000 }
validates :website, url: true, allow_blank: true, if: Proc.new { |c| c.new_record? || c.website_changed? }
@ -145,6 +146,9 @@ class UserProfile < ActiveRecord::Base
self.errors.add :base, (I18n.t('user.website.domain_not_allowed', domains: allowed_domains.split('|').join(", "))) unless allowed_domains.split('|').include?(domain)
end
def self.remove_featured_topic_from_all_profiles(topic)
where(featured_topic_id: topic.id).update_all(featured_topic_id: nil)
end
end
# == Schema Information

View File

@ -45,7 +45,8 @@ class CurrentUserSerializer < BasicUserSerializer
:second_factor_enabled,
:ignored_users,
:title_count_mode,
:timezone
:timezone,
:featured_topic
def groups
object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name.downcase } }
@ -217,4 +218,8 @@ class CurrentUserSerializer < BasicUserSerializer
def second_factor_enabled
object.totp_enabled? || object.security_keys_enabled?
end
def featured_topic
object.user_profile.featured_topic
end
end

View File

@ -83,7 +83,8 @@ class UserSerializer < BasicUserSerializer
:second_factor_remaining_backup_codes,
:associated_accounts,
:profile_background_upload_url,
:card_background_upload_url
:card_background_upload_url,
:featured_topic
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :groups, embed: :object, serializer: BasicGroupSerializer
@ -484,4 +485,7 @@ class UserSerializer < BasicUserSerializer
object.card_background_upload&.url
end
def featured_topic
object.user_profile.featured_topic
end
end

View File

@ -848,6 +848,7 @@ en:
enable_quoting: "Enable quote reply for highlighted text"
enable_defer: "Enable defer to mark topics unread"
change: "change"
featured_topic: "Featured Topic"
moderator: "{{user}} is a moderator"
admin: "{{user}} is an admin"
moderator_tooltip: "This user is a moderator"
@ -1045,6 +1046,10 @@ en:
title: "User Card Background"
instructions: "Background images will be centered and have a default width of 590px."
change_featured_topic:
title: "Featured Topic"
instructions: "To change this, either navigate to the topic to remove it as the featured topic, or feature a different topic."
email:
title: "Email"
primary: "Primary Email"
@ -1997,6 +2002,14 @@ en:
defer:
help: "Mark as unread"
title: "Defer"
feature_on_profile:
help: "Add a link to this topic on your user card and profile"
title: "Feature On Profile"
remove_from_profile:
warning: "Your profile already has a featured topic. If you continue, this topic will replace the existing topic."
help: "Remove the link to this topic on your user profile"
title: "Remove From Profile"
list: "Topics"
new: "new topic"
unread: "unread"

View File

@ -1951,6 +1951,8 @@ en:
hide_user_profiles_from_public: "Disable user cards, user profiles and user directory for anonymous users."
allow_featured_topic_on_user_profiles: "Allow users to feature a link to a topic on their user card and profile."
show_inactive_accounts: "Allow logged in users to browse profiles of inactive accounts."
hide_suspension_reasons: "Don't display suspension reasons publically on user profiles."

View File

@ -476,6 +476,8 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/profile-hidden" => "users#profile_hidden"
put "#{root_path}/:username/feature-topic" => "users#feature_topic", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/clear-featured-topic" => "users#clear_featured_topic", constraints: { username: RouteFormat.username }
end
get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json }

View File

@ -560,6 +560,9 @@ users:
hide_user_profiles_from_public:
default: false
client: true
allow_featured_topic_on_user_profiles:
default: true
client: true
show_inactive_accounts:
default: false
user_website_domains_whitelist:

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddFeaturedTopicIdToUserProfiles < ActiveRecord::Migration[6.0]
def change
add_column :user_profiles, :featured_topic_id, :integer
end
end

View File

@ -123,4 +123,11 @@ module UserGuardian
end
end
end
def can_feature_topic?(user, topic)
return false if !SiteSetting.allow_featured_topic_on_user_profiles?
return false if !is_me?(user) && !is_staff?
return false if topic.read_restricted_category? || topic.private_message?
topic.user_id === user.id
end
end

View File

@ -77,6 +77,7 @@ class PostDestroyer
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
if is_first_post
UserProfile.remove_featured_topic_from_all_profiles(@topic)
UserActionManager.topic_destroyed(topic)
DiscourseEvent.trigger(:topic_destroyed, topic, @user)
WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload)

View File

@ -125,6 +125,7 @@ module SvgSprite
"heading",
"heart",
"home",
"id-card",
"info-circle",
"italic",
"key",

View File

@ -822,4 +822,14 @@ describe PostDestroyer do
expect(@reviewable_reply.reload.status).to eq Reviewable.statuses[:approved]
end
end
describe "featured topics for user_profiles" do
fab!(:user) { Fabricate(:user) }
it 'clears the user_profiles featured_topic column' do
user.user_profile.update(featured_topic: post.topic)
PostDestroyer.new(admin, post).destroy
expect(user.user_profile.reload.featured_topic).to eq(nil)
end
end
end

View File

@ -221,5 +221,15 @@ describe TopicConverter do
expect(topic.reload.archetype).to eq("private_message")
end
end
context 'user_profiles with newly converted PM as featured topic' do
it "sets all matching user_profile featured topic ids to nil" do
author.user_profile.update(featured_topic: topic)
topic.convert_to_private_message(admin)
expect(author.user_profile.reload.featured_topic).to eq(nil)
end
end
end
end

View File

@ -2503,4 +2503,17 @@ describe Topic do
expect(topic.access_topic_via_group).to eq(open_group)
end
end
describe "#after_update" do
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:category) { Fabricate(:category_with_definition, read_restricted: true) }
it "removes the topic as featured from user profiles if new category is read_restricted" do
user.user_profile.update(featured_topic: topic)
expect(user.user_profile.featured_topic).to eq(topic)
topic.update(category: category)
expect(user.user_profile.reload.featured_topic).to eq(nil)
end
end
end

View File

@ -3831,4 +3831,96 @@ describe UsersController do
end
end
end
describe '#feature_topic' do
fab!(:topic) { Fabricate(:topic) }
fab!(:other_user) { Fabricate(:user) }
fab!(:private_message) { Fabricate(:private_message_topic, user: other_user) }
fab!(:category) { Fabricate(:category_with_definition) }
describe "site setting enabled" do
before do
SiteSetting.allow_featured_topic_on_user_profiles = true
end
it 'requires the user to be logged in' do
put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(403)
end
it 'returns an error if the the current user does not have access' do
sign_in(user)
topic.update(user_id: other_user.id)
put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(403)
end
it 'returns an error if the user did not create the topic' do
sign_in(user)
topic.update(user_id: other_user.id)
put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(403)
end
it 'returns an error if the topic is a PM' do
sign_in(other_user)
put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: private_message.id }
expect(response.status).to eq(403)
end
it "returns an error if the topic's category is read_restricted" do
sign_in(user)
category.set_permissions({})
topic.update(category_id: category.id)
put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(403)
end
it 'sets the user_profiles featured_topic correctly' do
sign_in(user)
topic.update(user_id: user.id)
put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(200)
expect(user.user_profile.featured_topic).to eq topic
end
describe "site setting disabled" do
before do
SiteSetting.allow_featured_topic_on_user_profiles = false
end
it "does not allow setting featured_topic for user_profiles" do
sign_in(user)
topic.update(user_id: user.id)
put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
expect(response.status).to eq(403)
end
end
end
end
describe '#clear_featured_topic' do
fab!(:topic) { Fabricate(:topic) }
fab!(:other_user) { Fabricate(:user) }
it 'requires the user to be logged in' do
put "/u/#{user.username}/clear-featured-topic.json"
expect(response.status).to eq(403)
end
it 'returns an error if the the current user does not have access' do
sign_in(user)
topic.update(user_id: other_user.id)
put "/u/#{other_user.username}/clear-featured-topic.json"
expect(response.status).to eq(403)
end
it 'clears the user_profiles featured_topic correctly' do
sign_in(user)
topic.update(user: user)
put "/u/#{user.username}/clear-featured-topic.json"
expect(response.status).to eq(200)
expect(user.user_profile.featured_topic).to eq nil
end
end
end

View File

@ -23,18 +23,13 @@ RSpec.describe WebHookUserSerializer do
it 'should only include the required keys' do
count = serializer.as_json.keys.count
difference = count - 44
difference = count - 45
expect(difference).to eq(0), lambda {
message = ""
if difference < 0
message << "#{difference * -1} key(s) have been removed from this serializer."
else
message << "#{difference} key(s) have been added to this serializer."
end
message << "\nPlease verify if those key(s) are required as part of the web hook's payload."
message = (difference < 0 ?
"#{difference * -1} key(s) have been removed from this serializer." :
"#{difference} key(s) have been added to this serializer.") +
"\nPlease verify if those key(s) are required as part of the web hook's payload."
}
end
end