Drop profile columns from user (#10707)

* Prepare to drop profile columns from user

* Update code and factory

* Also remove unused constant

* Move validation from user to profile

* Remove Profiles::ExtractData service object

* Add more comments

* Simplify sameAs attribute generation

* Obey me machine, I am your master

* Fix condition order in guard clause

* Temporarily disable callback

* Fix specs

* Reduce usage of Profile#refresh_attributes!

* Remove leftover comment

* Handle social media links differently

* More spec fixes

* Fix specs for admin profile fields controller

* Fix specs after merge

* Fix remaining specs

* Update user show request spec

* Add comment for follow_hiring_tag

* Only save profile when user is valid

* Fix seeds.rb for profile fields

* Switch from before_save to after_save

* Undo unrelated formattin change

* Update spec/fixtures/files/profile_fields.csv

Co-authored-by: Molly Struve <mollylbs@gmail.com>

* Remove data update script and spec

* Fix spec

* Fix typo in comment

* Fix typo in comment

* Move article resave logic to service object

* Move profile field creation to before(:suite)

* Refactor error handling in Profiles::Update

* Fix Profiles::Update specs and refactor

* Temporarily disable spec

* Add ProfileValidator

* Clean up

* Move DB ready check into app/lib

* Refresh attributes after importing from CSV

* Fix specs

* Remove unused file

* A girl has no name. A profile neither.

* Fix specs

* Add responds_to? check

* Spec fix

* Add name to user fields in profile settings page

Co-authored-by: Molly Struve <mollylbs@gmail.com>
This commit is contained in:
Michael Kohl 2020-12-03 08:14:38 +07:00 committed by GitHub
parent b02262c563
commit 8f5cfa2c0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 587 additions and 639 deletions

View file

@ -1,13 +1,13 @@
class ProfilesController < ApplicationController
before_action :authenticate_user!
ALLOWED_USER_PARAMS = %i[email username profile_image].freeze
ALLOWED_USER_PARAMS = %i[name email username profile_image].freeze
def update
update_result = Profiles::Update.call(current_user, update_params)
if update_result.success?
flash[:settings_notice] = "Your profile has been updated"
else
flash[:error] = "Error: #{update_result.error_message}"
flash[:error] = "Error: #{update_result.errors_as_sentence}"
end
redirect_to user_settings_path
end
@ -15,6 +15,6 @@ class ProfilesController < ApplicationController
private
def update_params
params.permit(profile: Profile.attributes!, user: ALLOWED_USER_PARAMS)
params.permit(profile: Profile.attributes, user: ALLOWED_USER_PARAMS)
end
end

View file

@ -478,17 +478,7 @@ class StoriesController < ApplicationController
[
@user.twitter_username.presence ? "https://twitter.com/#{@user.twitter_username}" : nil,
@user.github_username.presence ? "https://github.com/#{@user.github_username}" : nil,
@user.mastodon_url,
@user.facebook_url,
@user.youtube_url,
@user.linkedin_url,
@user.behance_url,
@user.stackoverflow_url,
@user.dribbble_url,
@user.medium_url,
@user.gitlab_url,
@user.instagram_url,
@user.website_url,
@user.profile.try(:website_url),
].reject(&:blank?)
end

View file

@ -1,76 +0,0 @@
module Constants
module Mastodon
ALLOWED_INSTANCES = [
"101010.pl",
"4estate.media",
"acg.mn",
"anarchism.space",
"bitcoinhackers.org",
"bsd.network",
"chaos.social",
"cmx.im",
"cybre.space",
"fosstodon.org",
"framapiaf.org",
"friends.nico",
"functional.cafe",
"hackers.town",
"hearthtodon.com",
"hex.bz",
"horiedon.com",
"hostux.social",
"im-in.space",
"imastodon.net",
"infosec.exchange",
"kirakiratter.com",
"knzk.me",
"linuxrocks.online",
"lou.lt",
"mamot.fr",
"mao.daizhige.org",
"mastodon.art",
"mastodon.at",
"mastodon.blue",
"mastodon.cloud",
"mastodon.gamedev.place",
"mastodon.host",
"mastodon.online",
"mastodon.sdf.org",
"mastodon.social",
"mastodon.technology",
"mastodon.xyz",
"mathtod.online",
"merveilles.town",
"mimumedon.com",
"misskey.xyz",
"moe.cat",
"mstdn-workers.com",
"mstdn.guru",
"mstdn.io",
"mstdn.jp",
"mstdn.tokyocameraclub.com",
"mstdn18.jp",
"music.pawoo.net",
"niu.moe",
"noagendasocial.com",
"octodon.social",
"otajodon.com",
"pawoo.net",
"phpc.social",
"qiitadon.com",
"radical.town",
"ro-mastodon.puyo.jp",
"ruby.social",
"ruhr.social",
"social.coop",
"social.targaryen.house",
"social.tchncs.de",
"switter.at",
"tech.lgbt",
"todon.nl",
"toot.cafe",
"wikitetas.club",
"xoxo.zone",
].freeze
end
end

13
app/lib/database.rb Normal file
View file

@ -0,0 +1,13 @@
# Database related utility functions
module Database
# Checks if the database is ready and the specified table exists. This can be
# useful during bin/setup and similar tasks. It also works if the connection
# hasn't been established yet.
#
# @param table [String] the name of the table to check for
def self.table_exists?(table)
ActiveRecord::Base.connection.table_exists?(table)
rescue ActiveRecord::NoDatabaseError
false
end
end

View file

@ -3,6 +3,7 @@ class Profile < ApplicationRecord
validates :data, presence: true
validates :user_id, uniqueness: true
validates_with ProfileValidator
has_many :custom_profile_fields, dependent: :destroy
@ -24,6 +25,8 @@ class Profile < ApplicationRecord
# Generates typed accessors for all currently defined profile fields.
def self.refresh_attributes!
return unless Database.table_exists?("profiles")
ProfileField.find_each do |field|
store_attribute :data, field.attribute_name.to_sym, field.type
end
@ -34,25 +37,6 @@ class Profile < ApplicationRecord
(stored_attributes[:data] || []).map(&:to_s)
end
# Forces a reload before returning attributes
def self.attributes!
refresh_attributes!
attributes
end
# NOTE: @citizen428 This is a temporary mapping so we don't break DEV during
# profile migration/generalization work.
def self.mapped_attributes
attributes!.map { |attribute| MAPPED_ATTRIBUTES.fetch(attribute, attribute).to_s }
end
# NOTE: @citizen428 We want to have a current list of profile attributes the
# moment the application loads. However, doing this unconditionally fails if
# the profiles table doesn't exist yet (e.g. when running bin/setup in a new
# clone). I wish Rails had a hook for code to run after the app started, but
# for now this is the best I can come up with.
refresh_attributes! if ApplicationRecord.connection.table_exists?("profiles")
def custom_profile_attributes
custom_profile_fields.pluck(:attribute_name)
end
@ -60,4 +44,9 @@ class Profile < ApplicationRecord
def clear!
update(data: {})
end
# NOTE: @citizen428 We want to have a current list of profile attributes the
# moment the application loads. I wish Rails had a hook for code to run after
# the app started...
refresh_attributes!
end

View file

@ -5,6 +5,42 @@ class User < ApplicationRecord
include Searchable
include Storext.model
# @citizen428 Preparing to drop profile columns from the users table
PROFILE_COLUMNS = %w[
available_for
behance_url
bg_color_hex
contact_consent
currently_hacking_on
currently_learning
currently_streaming_on
dribbble_url
education
email_public
employer_name
employer_url
employment_title
facebook_url
gitlab_url
instagram_url
linkedin_url
location
looking_for_work
looking_for_work_publicly
mastodon_url
medium_url
mostly_work_with
stackoverflow_url
summary
text_color_hex
twitch_url
twitch_username
website_url
youtube_url
].freeze
self.ignored_columns = PROFILE_COLUMNS
# NOTE: @citizen428 This is temporary code during profile migration and will
# be removed.
concerning :ProfileMigration do
@ -15,24 +51,21 @@ class User < ApplicationRecord
# instead. See `spec/factories/profiles.rb` for an example.
attr_accessor :_skip_creating_profile
# NOTE: used for not sync-ing back data to profiles when the profile got
# updated first. This will eventually be removed:
attr_accessor :_skip_profile_sync
# All new users should automatically have a profile
after_create_commit -> { Profile.create(user: self, data: Profiles::ExtractData.call(self)) },
unless: :_skip_creating_profile
after_create_commit -> { Profile.create(user: self) }, unless: :_skip_creating_profile
# Keep saving changes locally for the time being, but propagate them to profiles.
after_update_commit :sync_profile
def sync_profile
return if _skip_profile_sync
return unless previous_changes.keys.any? { |attribute| attribute.in?(Profile.mapped_attributes) }
profile.update(data: Profiles::ExtractData.call(self))
# Getters and setters for unmapped profile attributes
(PROFILE_COLUMNS - Profile::MAPPED_ATTRIBUTES.values).each do |column|
delegate column, "#{column}=", to: :profile, allow_nil: true
end
# Getters and setters for mapped profile attributes
Profile::MAPPED_ATTRIBUTES.each do |profile_attribute, user_attribute|
define_method(user_attribute) { profile&.public_send(profile_attribute) }
define_method("#{user_attribute}=") do |value|
profile&.public_send("#{profile_attribute}=", value)
end
end
private :sync_profile
end
end
@ -195,11 +228,9 @@ class User < ApplicationRecord
validates username_field, uniqueness: { allow_nil: true }, if: :"#{username_field}_changed?"
end
validate :conditionally_validate_summary
validate :non_banished_username, :username_changed?
validate :unique_including_orgs_and_podcasts, if: :username_changed?
validate :validate_feed_url, if: :feed_url_changed?
validate :validate_mastodon_url
validate :can_send_confirmation_email
validate :update_rate_limit
# NOTE: when updating the password on a Devise enabled model, the :encrypted_password
@ -224,9 +255,11 @@ class User < ApplicationRecord
before_create :set_default_language
before_destroy :unsubscribe_from_newsletters, prepend: true
before_destroy :destroy_follows, prepend: true
# NOTE: @citizen428 Temporary while migrating to generalized profiles
after_save { |user| user.profile&.save if user.profile&.changed? }
after_save :bust_cache
after_save :subscribe_to_mailchimp_newsletter
after_save :conditionally_resave_articles
after_create_commit :send_welcome_notification, :estimate_default_language
after_commit :index_to_elasticsearch, on: %i[create update]
@ -573,31 +606,10 @@ class User < ApplicationRecord
end
end
def conditionally_resave_articles
Users::ResaveArticlesWorker.perform_async(id) if core_profile_details_changed? && !banned
end
def bust_cache
Users::BustCacheWorker.perform_async(id)
end
def core_profile_details_changed?
saved_change_to_username? ||
saved_change_to_name? ||
saved_change_to_summary? ||
saved_change_to_bg_color_hex? ||
saved_change_to_text_color_hex? ||
saved_change_to_profile_image? ||
Authentication::Providers.username_fields.any? { |f| public_send("saved_change_to_#{f}?") }
end
def conditionally_validate_summary
# Grandfather people who had a too long summary before.
return if summary_was && summary_was.size > 200
errors.add(:summary, "is too long.") if summary.present? && summary.size > 200
end
def validate_feed_url
return if feed_url.blank?
@ -612,17 +624,6 @@ class User < ApplicationRecord
errors.add(:feed_url, e.message)
end
def validate_mastodon_url
return if mastodon_url.blank?
uri = URI.parse(mastodon_url)
return if uri.host&.in?(Constants::Mastodon::ALLOWED_INSTANCES)
errors.add(:mastodon_url, "is not an allowed Mastodon instance")
rescue URI::InvalidURIError
errors.add(:mastodon_url, "is not a valid URL")
end
def tag_keywords_for_search
"#{employer_name}#{mostly_work_with}#{available_for}"
end

View file

@ -0,0 +1,7 @@
module HashAnyKey
refine Hash do
def any_key?(keys)
(keys & Array.wrap(keys)).any?
end
end
end

View file

@ -1,5 +1,8 @@
module Moderator
class BanishUser < ManageActivityAndRoles
DEFAULT_PROFILE_IMAGE =
"https://thepracticaldev.s3.amazonaws.com/i/99mvlsfu5tfj9m7ku25d.png".freeze
attr_reader :user, :admin
def self.call(admin:, user:)
@ -39,21 +42,7 @@ module Moderator
def remove_profile_info
user.profile.clear!
# TODO: @forem/oss Remove this block once we drop the columns from users.
user._skip_profile_sync = true
user.update_columns(
twitter_username: nil, github_username: nil, website_url: "", summary: "",
location: "", education: "", employer_name: "", employer_url: "", employment_title: "",
mostly_work_with: "", currently_learning: "", currently_hacking_on: "", available_for: "",
email_public: false, facebook_url: nil, youtube_url: nil, dribbble_url: nil,
medium_url: nil, stackoverflow_url: nil,
behance_url: nil, linkedin_url: nil, gitlab_url: nil, instagram_url: nil, mastodon_url: nil,
twitch_url: nil, feed_url: nil
)
user._skip_profile_sync = false
user.update_columns(profile_image: "https://thepracticaldev.s3.amazonaws.com/i/99mvlsfu5tfj9m7ku25d.png")
user.update_columns(profile_image: DEFAULT_PROFILE_IMAGE)
end
def delete_vomit_reactions

View file

@ -4,7 +4,6 @@ module ProfileFields
group "Basic" do
field "Display email on profile", :check_box, display_area: "settings_only"
field "Name", :text_field, placeholder: "John Doe", display_area: "header"
field "Website URL", :text_field, placeholder: "https://yoursite.com", display_area: "header"
field "Summary", :text_area, placeholder: "A short bio...", display_area: "header"
field "Location", :text_field, placeholder: "Halifax, Nova Scotia", display_area: "header"

View file

@ -9,6 +9,7 @@ module ProfileFields
row[:profile_field_group] = ProfileFieldGroup.find_or_create_by(name: group)
ProfileField.find_or_create_by(row)
end
Profile.refresh_attributes!
end
end
end

View file

@ -1,14 +0,0 @@
module Profiles
module ExtractData
def self.call(user)
user_attributes = user.attributes
mapped_attributes = Profile::MAPPED_ATTRIBUTES.transform_values(&:to_s)
direct_attributes = Profile.attributes! - mapped_attributes.keys
direct_data = user_attributes.extract!(*direct_attributes)
mapped_data = mapped_attributes.keys.zip(user_attributes.values_at(*mapped_attributes.values)).to_h
direct_data.merge(mapped_data)
end
end
end

View file

@ -1,29 +1,37 @@
module Profiles
class Update
using HashAnyKey
include ImageUploads
USER_COLUMNS = Set.new(User.column_names).freeze
CORE_PROFILE_FIELDS = %i[summary brand_color1 brand_color2].freeze
CORE_USER_FIELDS = %i[name username profile_image].freeze
def self.call(user, updated_attributes = {})
new(user, updated_attributes).call
end
attr_reader :error_message
def initialize(user, updated_attributes)
@user = user
@profile = user.profile
@updated_profile_attributes = updated_attributes[:profile] || {}
@updated_user_attributes = updated_attributes[:user].to_h || {}
@errors = []
@success = false
end
def call
if verify_profile_image && update_profile && sync_to_user
if update_successful?
@success = true
@user.touch(:profile_updated_at)
follow_hiring_tag
# TODO: @citizen428 Preserving a DEV specific feature for now, we should
# probably remove this sooner than later as it may not make much sense
# for other communities.
follow_hiring_tag if SiteConfig.dev_to?
conditionally_resave_articles
else
Honeycomb.add_field("error", @error_message)
errors.concat(@profile.errors.full_messages)
errors.concat(@user.errors.full_messages)
Honeycomb.add_field("error", errors_as_sentence)
Honeycomb.add_field("errored", true)
end
self
@ -33,8 +41,26 @@ module Profiles
@success
end
def errors_as_sentence
errors.to_sentence
end
private
attr_reader :errors
def update_successful?
return false unless verify_profile_image
Profile.transaction do
update_profile
@user.update!(@updated_user_attributes)
end
true
rescue ActiveRecord::RecordInvalid
false
end
def verify_profile_image
image = @updated_user_attributes[:profile_image]
return true unless image
@ -46,21 +72,18 @@ module Profiles
def valid_image_file?(image)
return true if file?(image)
@error_message = IS_NOT_FILE_MESSAGE
errors.append(IS_NOT_FILE_MESSAGE)
false
end
def valid_filename?(image)
return true unless long_filename?(image)
@error_message = FILENAME_TOO_LONG_MESSAGE
errors.append(FILENAME_TOO_LONG_MESSAGE)
false
end
def update_profile
# Ensure we have up to date attributes
Profile.refresh_attributes!
# Handle user specific custom profile fields
if (custom_profile_attributes = @profile.custom_profile_attributes).any?
custom_attributes = @updated_profile_attributes.extract!(*custom_profile_attributes)
@ -74,46 +97,28 @@ module Profiles
# Before saving, filter out obsolete profile fields
@profile.data.slice!(*Profile.attributes)
@profile.save
end
# Propagate changes back to the `users` table
def sync_to_user
@profile.user._skip_profile_sync = true
if @profile.user.update(user_profile_attributes)
update_user_attributes
else
@error_message = @user.errors_as_sentence
end
@success
ensure
@profile.user._skip_profile_sync = false
end
# These are the profile attributes that still exist as columns on User.
def user_profile_attributes
profile_attributes = @profile.data.transform_keys do |key|
Profile::MAPPED_ATTRIBUTES.fetch(key, key).to_s
end
profile_attributes.except("custom_attributes").select { |key, _| key.in?(USER_COLUMNS) }
end
def update_user_attributes
if @user.update(@updated_user_attributes)
@success = true
else
@error_message = @user.errors_as_sentence
end
@profile.save!
end
def follow_hiring_tag
return unless SiteConfig.dev_to? && @user.looking_for_work?
return unless @user.looking_for_work
hiring_tag = Tag.find_by(name: "hiring")
return unless hiring_tag && @user.following?(hiring_tag)
Users::FollowWorker.perform_async(@user.id, hiring_tag.id, "Tag")
end
def conditionally_resave_articles
return unless core_profile_details_changed? && !@user.banned
Users::ResaveArticlesWorker.perform_async(@user.id)
end
def core_profile_details_changed?
user_fields = CORE_USER_FIELDS + Authentication::Providers.username_fields
@updated_user_attributes.any_key?(user_fields) ||
@updated_profile_attributes.any_key?(CORE_PROFILE_FIELDS)
end
end
end

View file

@ -0,0 +1,63 @@
class ProfileValidator < ActiveModel::Validator
SUMMARY_ATTRIBUTE = "summary".freeze
MAX_SUMMARY_LENGTH = 200
MAX_TEXT_AREA_LENGTH = 200
MAX_TEXT_FIELD_LENGTH = 100
HEX_COLOR_REGEXP = /^#?(?:\h{6}|\h{3})$/.freeze
ERRORS = {
color_field: "is not a valid hex color",
text_area: "is too long (maximum: #{MAX_TEXT_AREA_LENGTH})",
text_field: "is too long (maximum: #{MAX_TEXT_FIELD_LENGTH})"
}.with_indifferent_access.freeze
def validate(record)
# NOTE: @citizen428 The summary is a base profile field, which we add to all
# new Forem instances, so it should be safe to validate. The method itself
# also guards against the field's absence.
record.errors.add(:summary, "is too long") if summary_too_long?(record)
ProfileField.all.each do |field|
attribute = field.attribute_name
next if attribute == SUMMARY_ATTRIBUTE # validated above
next unless record.respond_to?(attribute) # avoid caching issues
next if __send__("#{field.input_type}_valid?", record, attribute)
record.errors.add(attribute, ERRORS[field.input_type])
end
end
private
def summary_too_long?(record)
return unless ProfileField.exists?(attribute_name: SUMMARY_ATTRIBUTE)
return if record.summary.blank?
# Grandfather in people who had a too long summary before
previous_summary = record.data_was[SUMMARY_ATTRIBUTE]
return if previous_summary && previous_summary.size > MAX_SUMMARY_LENGTH
record.summary.size > MAX_SUMMARY_LENGTH
end
def check_box_valid?(_record, _attribute)
true # checkboxes are always valid
end
def color_field_valid?(record, attribute)
hex_value = record.public_send(attribute)
hex_value.nil? || hex_value.match?(HEX_COLOR_REGEXP)
end
def text_area_valid?(record, attribute)
text = record.public_send(attribute)
text.nil? || text.size <= MAX_TEXT_AREA_LENGTH
end
def text_field_valid?(record, attribute)
text = record.public_send(attribute)
text.nil? || text.size <= MAX_TEXT_FIELD_LENGTH
end
end

View file

@ -1,6 +1,6 @@
<% @grouped_profile_fields.each do |group| %>
<% group_name = group.name.gsub(/\s+/, "_") %>
<article class="row my-3">
<article id="profile-field-group-<%= group.id %>" class="row my-3">
<div class="card w-100">
<div class="card-header">
<div class="card-header__information">
@ -27,7 +27,7 @@
<div> There are no profile fields configured for this group. </div>
<% else %>
<% group.profile_fields.each do |field| %>
<div class="card mt-3">
<div id="profile-field-<%= field.id %>" class="card mt-3">
<%= render partial: "admin/shared/card_header",
locals: {
header: field.label,

View file

@ -19,6 +19,12 @@
<div class="crayons-card crayons-card--content-rows">
<h2>User</h2>
<div class="crayons-field">
<%= f.label :name, class: "crayons-field__label" %>
<%= f.text_field "user[name]", maxlength: 30, class: "crayons-textfield", placeholder: "John Doe", value: @user.name %>
</div>
<div class="crayons-field">
<%= f.label :email, class: "crayons-field__label" %>
<%= f.text_field "user[email]", class: "crayons-textfield", placeholder: "john.doe@example.com", value: @user.email %>

View file

@ -83,65 +83,67 @@
<%= inline_svg_tag("github.svg", class: "crayons-icon", aria: true, title: "GitHub logo") %>
</a>
<% end %>
<% if @user.mastodon_url? %>
<a href="<%= @user.mastodon_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("mastodon.svg", class: "crayons-icon", aria: true, title: "Mastodon logo") %>
</a>
<% end %>
<% if @user.facebook_url? %>
<a href="<%= @user.facebook_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("facebook.svg", class: "crayons-icon", aria: true, title: "Facebook logo") %>
</a>
<% end %>
<% if @user.youtube_url? %>
<a href="<%= @user.youtube_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("youtube.svg", class: "crayons-icon", aria: true, title: "Youtube logo") %>
</a>
<% end %>
<% if @user.linkedin_url? %>
<a href="<%= @user.linkedin_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("linkedin.svg", class: "crayons-icon", aria: true, title: "LinkedIn logo") %>
</a>
<% end %>
<% if @user.behance_url? %>
<a href="<%= @user.behance_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("behance.svg", class: "crayons-icon", aria: true, title: "Behance logo") %>
</a>
<% end %>
<% if @user.stackoverflow_url? %>
<a href="<%= @user.stackoverflow_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("stackoverflow.svg", class: "crayons-icon", aria: true, title: "StackOverflow logo") %>
</a>
<% end %>
<% if @user.dribbble_url? %>
<a href="<%= @user.dribbble_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("dribbble.svg", class: "crayons-icon", aria: true, title: "Dribbble logo") %>
</a>
<% end %>
<% if @user.medium_url? %>
<a href="<%= @user.medium_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("medium.svg", class: "crayons-icon", aria: true, title: "Medium logo") %>
</a>
<% end %>
<% if @user.gitlab_url? %>
<a href="<%= @user.gitlab_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("gitlab.svg", class: "crayons-icon", aria: true, title: "GitLab logo") %>
</a>
<% end %>
<% if @user.instagram_url? %>
<a href="<%= @user.instagram_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("instagram.svg", class: "crayons-icon", aria: true, title: "Instagram logo") %>
</a>
<% end %>
<% if @user.twitch_url? %>
<a href="<%= @user.twitch_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("twitch.svg", class: "crayons-icon", aria: true, title: "Twitch logo") %>
</a>
<% end %>
<% if @user.website_url? %>
<a href="<%= @user.website_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("external.svg", class: "crayons-icon", aria: true, title: "External link icon") %>
</a>
<% if SiteConfig.dev_to? %>
<% if @user.mastodon_url %>
<a href="<%= @user.mastodon_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("mastodon.svg", class: "crayons-icon", aria: true, title: "Mastodon logo") %>
</a>
<% end %>
<% if @user.facebook_url %>
<a href="<%= @user.facebook_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("facebook.svg", class: "crayons-icon", aria: true, title: "Facebook logo") %>
</a>
<% end %>
<% if @user.youtube_url %>
<a href="<%= @user.youtube_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("youtube.svg", class: "crayons-icon", aria: true, title: "Youtube logo") %>
</a>
<% end %>
<% if @user.linkedin_url %>
<a href="<%= @user.linkedin_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("linkedin.svg", class: "crayons-icon", aria: true, title: "LinkedIn logo") %>
</a>
<% end %>
<% if @user.behance_url %>
<a href="<%= @user.behance_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("behance.svg", class: "crayons-icon", aria: true, title: "Behance logo") %>
</a>
<% end %>
<% if @user.stackoverflow_url %>
<a href="<%= @user.stackoverflow_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("stackoverflow.svg", class: "crayons-icon", aria: true, title: "StackOverflow logo") %>
</a>
<% end %>
<% if @user.dribbble_url %>
<a href="<%= @user.dribbble_url %>" target="_blank" rel="noopener me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("dribbble.svg", class: "crayons-icon", aria: true, title: "Dribbble logo") %>
</a>
<% end %>
<% if @user.medium_url %>
<a href="<%= @user.medium_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("medium.svg", class: "crayons-icon", aria: true, title: "Medium logo") %>
</a>
<% end %>
<% if @user.gitlab_url %>
<a href="<%= @user.gitlab_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("gitlab.svg", class: "crayons-icon", aria: true, title: "GitLab logo") %>
</a>
<% end %>
<% if @user.instagram_url %>
<a href="<%= @user.instagram_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("instagram.svg", class: "crayons-icon", aria: true, title: "Instagram logo") %>
</a>
<% end %>
<% if @user.twitch_url %>
<a href="<%= @user.twitch_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("twitch.svg", class: "crayons-icon", aria: true, title: "Twitch logo") %>
</a>
<% end %>
<% if @user.website_url %>
<a href="<%= @user.website_url %>" target="_blank" rel="noopener nofollow me" class="px-1 align-middle inline-block">
<%= inline_svg_tag("external.svg", class: "crayons-icon", aria: true, title: "External link icon") %>
</a>
<% end %>
<% end %>
</span>
</div>

View file

@ -9,6 +9,13 @@ class Seeder
@counter = 0
end
# Used when the block is idempotent by itself and needs no further checks.
def create(message)
@counter += 1
puts " #{@counter}. #{message}."
yield
end
def create_if_none(klass, count = nil)
@counter += 1
plural = klass.name.pluralize
@ -75,6 +82,16 @@ end
##############################################################################
# NOTE: @citizen428 For the time being we want all current DEV profile fields.
# The CSV import is idempotent by itself, since it uses find_or_create_by.
seeder.create("Creating DEV profile fields") do
dev_fields_csv = Rails.root.join("lib/data/dev_profile_fields.csv")
ProfileFields::ImportFromCsv.call(dev_fields_csv)
end
##############################################################################
num_users = 10 * SEEDS_MULTIPLIER
users_in_random_order = seeder.create_if_none(User, num_users) do
@ -311,7 +328,7 @@ seeder.create_if_none(Broadcast) do
"Consider <a href='/settings'>connecting it</a> so we can @mention you if we share your post " \
"via our Twitter account <a href='https://twitter.com/thePracticalDev'>@thePracticalDev</a>.",
facebook_connect: "You're on a roll! 🎉 Do you have a Facebook account? " \
"Consider <a href='/settings'>connecting it</a>.",
"Consider <a href='/settings'>connecting it</a>.",
github_connect: "You're on a roll! 🎉 Do you have a GitHub account? " \
"Consider <a href='/settings'>connecting it</a> so you can pin any of your repos to your profile.",
customize_feed: "Hi, it's me again! 👋 Now that you're a part of the DEV community, let's focus on personalizing " \
@ -320,7 +337,7 @@ seeder.create_if_none(Broadcast) do
"Try changing <a href='settings/customization'>your font and theme</a> and find the best style for you!",
start_discussion: "Sloan here! 👋 I noticed that you haven't " \
"<a href='https://dev.to/t/discuss'>started a discussion</a> yet. Starting a discussion is easy to do; " \
"just click on 'Write a Post' in the sidebar of the tag page to get started!",
"just click on 'Write a Post' in the sidebar of the tag page to get started!",
ask_question: "Sloan here! 👋 I noticed that you haven't " \
"<a href='https://dev.to/t/explainlikeimfive'>asked a question</a> yet. Asking a question is easy to do; " \
"just click on 'Write a Post' in the sidebar of the tag page to get started!",
@ -565,17 +582,6 @@ end
##############################################################################
seeder.create_if_none(ProfileField) do
ProfileFields::AddBaseFields.call
ProfileFields::AddLinkFields.call
ProfileFields::AddWorkFields.call
coding_fields_csv = Rails.root.join("lib/data/coding_profile_fields.csv")
ProfileFields::ImportFromCsv.call(coding_fields_csv)
ProfileFields::AddBrandingFields.call
end
##############################################################################
seeder.create_if_none(Sponsorship) do
organizations = Organization.take(3)
organizations.each do |organization|

View file

@ -1,4 +0,0 @@
Skills/Languages,text_area,,What tools and languages are you most experienced with? Are you specialized or more of a generalist?,Coding
Currently learning,text_area,,What are you learning right now? What are the new tools and languages you're picking up right now?,Coding
Currently hacking on,text_area,,What projects are currently occupying most of your time?,Coding
Available for,text_area,,What kinds of collaborations or discussions are you available for? What's a good reason to say Hey! to you these days?,Coding
1 Skills/Languages text_area What tools and languages are you most experienced with? Are you specialized or more of a generalist? Coding
2 Currently learning text_area What are you learning right now? What are the new tools and languages you're picking up right now? Coding
3 Currently hacking on text_area What projects are currently occupying most of your time? Coding
4 Available for text_area What kinds of collaborations or discussions are you available for? What's a good reason to say Hey! to you these days? Coding

View file

@ -1,5 +1,4 @@
Display email on profile,check_box,,,Basic,settings_only
Name,text_field,John Doe,,Basic,header
Website URL,text_field,https://yoursite.com,,Basic,header
Summary,text_area,A short bio...,,Basic,header
Location,text_field,"Halifax, Nova Scotia",,Basic,header

1 Display email on profile check_box Basic settings_only
Name text_field John Doe Basic header
2 Website URL text_field https://yoursite.com Basic header
3 Summary text_area A short bio... Basic header
4 Location text_field Halifax, Nova Scotia Basic header

View file

@ -2,11 +2,11 @@ module DataUpdateScripts
class MigrateProfileData
def run
User.find_each do |user|
# NOTE: no production users have profiles yet, but we want this script
# to be idempotent.
next if user.profile.present?
# NOTE: This script is a no-op now, as we have removed the needed service
# object.
# next if user.profile.present?
Profile.create(user: user, data: Profiles::ExtractData.call(user))
# Profile.create(user: user, data: Profiles::ExtractData.call(user))
end
end
end

View file

@ -1,28 +1,8 @@
module DataUpdateScripts
class PrepareForProfileColumnDrop
HONEYCOMB_PREFIX = "data_update_20201103050112".freeze
def run
# Make sure all current DEV profile fields exist. The import task is
# idempotent so we don't need further checkes here.
dev_fields_csv = Rails.root.join("lib/data/dev_profile_fields.csv")
ProfileFields::ImportFromCsv.call(dev_fields_csv)
# Make sure all current profile data is migrated before we remove the
# column from User. Also ensure we don't lose any data for custom fields,
# even if these got temporarily disabled by a feature flag.
User.includes(:profile).order(:id).find_each do |user|
profile = user.profile
user_data = Profiles::ExtractData.call(user)
profile.update(data: profile.data.merge(user_data).compact)
rescue StandardError => e
Honeycomb.add_field("#{HONEYCOMB_PREFIX}.class", e.class)
Honeycomb.add_field("#{HONEYCOMB_PREFIX}.message", e.message)
Honeycomb.add_field("#{HONEYCOMB_PREFIX}.user_id", e.user_id)
next
ensure
Rails.cache.write(HONEYCOMB_PREFIX, user.id, expires_in: 48.hours)
end
# This script is no a no-op as the previously used service object
# no longer exists.
end
end
end

View file

@ -0,0 +1,7 @@
module DataUpdateScripts
class RemoveNameProfileField
def run
ProfileField.find_by(attribute_name: :name)&.destroy
end
end
end

View file

@ -2,4 +2,30 @@ FactoryBot.define do
factory :profile do
user { association(:user, _skip_creating_profile: true) }
end
trait :with_DEV_info do
data do
{
behance_url: "www.behance.net/#{user.username}",
currently_hacking_on: "JSON-LD",
currently_learning: "Preact",
dribbble_url: "www.dribbble.com/example",
education: "DEV University",
employer_name: "DEV",
employer_url: "http://dev.to",
employment_title: "Software Engineer",
facebook_url: "www.facebook.com/example",
gitlab_url: "www.gitlab.com/example",
instagram_url: "www.instagram.com/example",
linkedin_url: "www.linkedin.com/company/example",
mastodon_url: "https://mastodon.social/@test",
medium_url: "www.medium.com/example",
skills_languages: "Ruby",
stackoverflow_url: "www.stackoverflow.com/example",
youtube_url: "https://youtube.com/example",
summary: "I do things with computers",
website_url: "http://example.com"
}
end
end
end

View file

@ -13,8 +13,6 @@ FactoryBot.define do
profile_image { Rack::Test::UploadedFile.new(image_path, "image/jpeg") }
twitter_username { generate :twitter_username }
github_username { generate :github_username }
summary { Faker::Lorem.paragraph[0..rand(190)] }
website_url { Faker::Internet.url }
confirmed_at { Time.current }
saw_onboarding { true }
checked_code_of_conduct { true }
@ -23,8 +21,6 @@ FactoryBot.define do
registered_at { Time.current }
signup_cta_variant { "navbar_basic" }
email_digest_periodic { false }
bg_color_hex { Faker::Color.hex_color }
text_color_hex { Faker::Color.hex_color }
trait :with_identity do
transient { identities { Authentication::Providers.available } }
@ -146,26 +142,6 @@ FactoryBot.define do
end
end
trait :with_all_info do
education { "DEV University" }
employment_title { "Software Engineer" }
employer_name { "DEV" }
employer_url { "http://dev.to" }
currently_learning { "Preact" }
mostly_work_with { "Ruby" }
currently_hacking_on { "JSON-LD" }
mastodon_url { "https://mastodon.social/@test" }
facebook_url { "www.facebook.com/example" }
linkedin_url { "www.linkedin.com/company/example" }
youtube_url { "https://youtube.com/example" }
behance_url { "www.behance.net/#{username}" }
stackoverflow_url { "www.stackoverflow.com/example" }
dribbble_url { "www.dribbble.com/example" }
medium_url { "www.medium.com/example" }
gitlab_url { "www.gitlab.com/example" }
instagram_url { "www.instagram.com/example" }
end
trait :without_profile do
_skip_creating_profile { true }
end

View file

@ -1,4 +1,4 @@
Name,text_field,John Doe,,Basic,header
Skills/Languages,text_area,,Programming languages,Coding,left_sidebar
Test name,text_field,John Doe,,Basic,header
Test languages,text_area,,Programming languages,Coding,left_sidebar
Color,color_field,#000000,"Used for backgrounds, borders etc.",Branding,settings_only
Test color,color_field,#000000,"Used for backgrounds, borders etc.",Branding,settings_only

1 Name Test name text_field John Doe Basic header
2 Skills/Languages Test languages text_area Programming languages Coding left_sidebar
3 Color Test color color_field #000000 Used for backgrounds, borders etc. Branding settings_only
4

View file

@ -15,7 +15,7 @@ describe DataUpdateScripts::CreateProfileFields do
it "creates all profile fields and groups" do
expect do
described_class.new.run
end.to change { profile_field_and_group_count }.from([0, 0]).to([29, 5])
end.to change { profile_field_and_group_count }.from([0, 0]).to([28, 5])
end
end
@ -29,7 +29,7 @@ describe DataUpdateScripts::CreateProfileFields do
expect do
described_class.new.run
end.not_to change { profile_field_and_group_count }
expect(profile_field_and_group_count).to eq [29, 5]
expect(profile_field_and_group_count).to eq [28, 5]
end
end
end

View file

@ -1,16 +0,0 @@
require "rails_helper"
require Rails.root.join("lib/data_update_scripts/20200819025131_migrate_profile_data.rb")
describe DataUpdateScripts::MigrateProfileData do
it "creates a profile for users who don't have one" do
user = create(:user, :without_profile)
expect do
described_class.new.run
end.to change { user.reload.profile }.from(nil).to(an_instance_of(Profile))
end
it "does nothing for users with existing profiles" do
user = create(:user)
expect { described_class.new.run }.not_to change { user.reload.profile }
end
end

View file

@ -1,33 +0,0 @@
require "rails_helper"
require Rails.root.join(
"lib/data_update_scripts/20201103050112_prepare_for_profile_column_drop.rb",
)
describe DataUpdateScripts::PrepareForProfileColumnDrop do
context "when using only DEV profile fields" do
it "migrates data from the user to the profile", :aggregate_failures do
summary = "I hack on profiles a lot"
user = create(:user, summary: summary)
expect(user.profile).not_to respond_to(:summary)
described_class.new.run
expect(user.profile.reload.summary).to eq(summary)
end
end
context "when a Forem used additional profile fields" do
let!(:user) { create(:user) }
before do
create(:profile_field, label: "Doge Test")
Profile.refresh_attributes!
user.profile.update(doge_test: "Such update, much wow!")
end
it "migrates data and keeps existing data intact", :aggregate_failures do
described_class.new.run
expect(user.profile.reload.data.keys.size).to eq(11)
expect(user.profile.data).to have_key("doge_test")
end
end
end

View file

@ -81,7 +81,9 @@ RSpec.describe Organization, type: :model do
expect(article.elasticsearch_doc.dig("_source", "organization", "name")).to eq(new_org_name)
end
it "on destroy removes data from elasticsearch" do
# TODO: This will be fixed by @mstruve
# https://github.com/forem/forem/pull/10707#pullrequestreview-538071192
xit "on destroy removes data from elasticsearch" do
article = create(:article, organization: organization)
sidekiq_perform_enqueued_jobs
expect(article.elasticsearch_doc.dig("_source", "organization", "id")).to eq(organization.id)

View file

@ -1,11 +1,97 @@
require "rails_helper"
RSpec.describe Profile, type: :model do
let(:user) { create(:user) }
let(:profile) { user.profile }
describe "validations" do
subject { create(:profile) }
subject { profile }
it { is_expected.to validate_uniqueness_of(:user_id) }
it { is_expected.to validate_presence_of(:data) }
describe "conditionally validating summary" do
let(:invalid_summary) { "x" * ProfileValidator::MAX_SUMMARY_LENGTH.next }
it "doesn't validate if the profile field doesn't exist" do
allow(ProfileField).to receive(:exists?).with(attribute_name: "summary").and_return(false)
profile.summary = invalid_summary
expect(profile).to be_valid
end
it "is valid if users previously had long summaries and are grandfathered" do
profile.summary = invalid_summary
profile.save(validate: false)
profile.summary = "x" * 999
expect(profile).to be_valid
end
it "is not valid if the summary is too long and the user is not grandfathered" do
profile.summary = invalid_summary
expect(profile).not_to be_valid
expect(profile.errors_as_sentence).to eq "Summary is too long"
end
it "is valid if the summary is less than the limit" do
profile.summary = "Hello 👋"
expect(profile).to be_valid
end
end
describe "validating color fields" do
it "is valid if the field is a correct hex color with leading #" do
profile.brand_color1 = "#abcdef"
expect(profile).to be_valid
end
it "is valid if the field is a correct hex color without leading #" do
profile.brand_color1 = "abcdef"
expect(profile).to be_valid
end
it "is valid if the field is a 3-digit hex color" do
profile.brand_color1 = "#ccc"
expect(profile).to be_valid
end
it "is invalid if the field is too long" do
profile.brand_color1 = "#deadbeef"
expect(profile).not_to be_valid
expect(profile.errors_as_sentence).to eq "Brand color1 is not a valid hex color"
end
it "is invalid if the field contains non hex characters" do
profile.brand_color1 = "#abcdeg"
expect(profile).not_to be_valid
expect(profile.errors_as_sentence).to eq "Brand color1 is not a valid hex color"
end
end
describe "validating text areas" do
it "is valid if the text is short enough" do
profile.skills_languages = "Ruby"
expect(profile).to be_valid
end
it "is invalid if the text is too long" do
profile.skills_languages = "x" * ProfileValidator::MAX_TEXT_AREA_LENGTH.next
expect(profile).not_to be_valid
expect(profile.errors_as_sentence).to eq "Skills languages is too long (maximum: 200)"
end
end
describe "validating text fields" do
it "is valid if the text is short enough" do
profile.location = "Somewhere"
expect(profile).to be_valid
end
it "is invalid if the text is too long" do
profile.location = "x" * ProfileValidator::MAX_TEXT_FIELD_LENGTH.next
expect(profile).not_to be_valid
expect(profile.errors_as_sentence).to eq "Location is too long (maximum: 100)"
end
end
end
context "when accessing profile fields" do

View file

@ -443,18 +443,6 @@ RSpec.describe User, type: :model do
expect(user.old_username).to eq(new_username)
expect(user.old_old_username).to eq(old_username)
end
it "enforces summary length validation if previous summary was valid" do
user.summary = "0" * 999
user.save(validate: false)
user.summary = "0" * 999
expect(user).to be_valid
end
it "does not enforce summary validation if previous summary was invalid" do
user = build(:user, summary: "0" * 999)
expect(user).not_to be_valid
end
end
end
@ -591,98 +579,6 @@ RSpec.describe User, type: :model do
end
end
end
describe "#conditionally_resave_articles" do
let!(:user) { create(:user) }
it "enqueues resave articles job when changing username" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.username = "#{user.username} changed"
user.save
end
end
it "enqueues resave articles job when changing name" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.name = "#{user.name} changed"
user.save
end
end
it "enqueues resave articles job when changing summary" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.summary = "#{user.summary} changed"
user.save
end
end
it "enqueues resave articles job when changing bg_color_hex" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.bg_color_hex = "#12345F"
user.save
end
end
it "enqueues resave articles job when changing text_color_hex" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.text_color_hex = "#FA345E"
user.save
end
end
it "enqueues resave articles job when changing profile_image" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.profile_image = "https://fakeimg.pl/300/"
user.save
end
end
Authentication::Providers.username_fields.each do |username_field|
it "enqueues resave articles job when changing #{username_field}" do
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
) do
user.assign_attributes(username_field => "greatnewusername")
user.save
end
end
it "doesn't enqueue resave articles job when changing #{username_field} for a banned user" do
banned_user = create(:user, :banned)
expect do
banned_user.assign_attributes(username_field => "greatnewusername")
banned_user.save
end.not_to change(Users::ResaveArticlesWorker.jobs, :size)
end
end
end
end
describe "user registration", vcr: { cassette_name: "fastly_sloan" } do
@ -1059,12 +955,6 @@ RSpec.describe User, type: :model do
end
describe "profiles" do
before do
create(:profile_field, label: "Available for")
create(:profile_field, label: "Brand Color 1")
Profile.refresh_attributes!
end
it "automatically creates a profile for new users", :aggregate_failures do
user = create(:user)
expect(user.profile).to be_present

View file

@ -90,6 +90,12 @@ RSpec.configure do |config|
ENV["TZ"] = Time.zone.tzinfo.name
Search::Cluster.recreate_indexes
# NOTE: @citizen428 needed while we delegate from User to Profile to keep
# spec changes limited for the time being.
csv = Rails.root.join("lib/data/dev_profile_fields.csv")
ProfileFields::ImportFromCsv.call(csv)
Profile.refresh_attributes!
end
config.before do

View file

@ -29,7 +29,7 @@ RSpec.describe "/admin/profile_fields", type: :request do
describe "POST /admin/profile_fields" do
let(:new_profile_field) do
{
label: "Location",
label: "Test Location",
input_type: "text_field",
description: "users' location",
placeholder_text: "new york"
@ -73,7 +73,7 @@ RSpec.describe "/admin/profile_fields", type: :request do
end
describe "DELETE /admin/profile_fields/:id" do
let(:profile_field) do
let!(:profile_field) do
create(:profile_field).tap { Profile.refresh_attributes! }
end
@ -83,8 +83,9 @@ RSpec.describe "/admin/profile_fields", type: :request do
end
it "removes a profile field" do
delete "#{admin_profile_fields_path}/#{profile_field.id}"
expect(ProfileField.count).to eq(0)
expect do
delete "#{admin_profile_fields_path}/#{profile_field.id}"
end.to change(ProfileField, :count).by(-1)
end
end
end

View file

@ -6,13 +6,14 @@ RSpec.describe "ProfileFieldGroups", type: :request do
describe "GET /profile_field_groups" do
let!(:group1) { create(:profile_field_group) }
let!(:group2) { create(:profile_field_group) }
let!(:field1) { create(:profile_field, :onboarding, label: "Field 1", profile_field_group: group1) }
before do
sign_in user
create(:profile_field, :onboarding, label: "Field 1", profile_field_group: group1)
create(:profile_field, label: "Field 2", profile_field_group: group1)
create(:profile_field, label: "Field 3", profile_field_group: group2)
Profile.refresh_attributes!
sign_in user
end
it "returns a successful response" do
@ -37,6 +38,7 @@ RSpec.describe "ProfileFieldGroups", type: :request do
json_response = JSON.parse(response.body, symbolize_names: true)
group = json_response[:profile_field_groups].first
expect(group[:profile_fields].size).to eq 1
field1 = ProfileField.find_by(label: "Field 1")
expect(group[:profile_fields].first[:id]).to eq field1.id
end
end

View file

@ -12,24 +12,20 @@ RSpec.describe "Profiles", type: :request do
end
context "when signed in" do
before do
create(:profile_field, label: "Name")
Profile.refresh_attributes!
sign_in(profile.user)
before { sign_in(profile.user) }
it "updates the user" do
new_name = "New name, who dis?"
expect do
patch profile_path(profile), params: { user: { name: new_name } }
end.to change { profile.user.reload.name }.to(new_name)
end
it "updates the profile" do
new_name = "New name, who dis?"
new_location = "New location, who dis?"
expect do
patch profile_path(profile), params: { profile: { name: new_name } }
end.to change { profile.reload.name }.to(new_name)
end
it "syncs the changes back to the user" do
new_name = "New name, who dis?"
expect do
patch profile_path(profile), params: { profile: { name: new_name } }
end.to change { profile.user.reload.name }.to(new_name)
patch profile_path(profile), params: { profile: { location: new_location } }
end.to change { profile.reload.location }.to(new_location)
end
end
end

View file

@ -1,7 +1,15 @@
require "rails_helper"
RSpec.describe "UserShow", type: :request do
let(:user) { create(:user, :with_all_info, email_public: true) }
let!(:profile) do
create(
:profile,
:with_DEV_info,
user: create(:user, :without_profile),
display_email_on_profile: true,
)
end
let(:user) { profile.user }
describe "GET /:slug (user)" do
it "returns a 200 status when navigating to the user's page" do
@ -25,17 +33,7 @@ RSpec.describe "UserShow", type: :request do
"sameAs" => [
"https://twitter.com/#{user.twitter_username}",
"https://github.com/#{user.github_username}",
user.mastodon_url,
user.facebook_url,
user.youtube_url,
user.linkedin_url,
user.behance_url,
user.stackoverflow_url,
user.dribbble_url,
user.medium_url,
user.gitlab_url,
user.instagram_url,
user.website_url,
"http://example.com",
],
"image" => Images::Profile.call(user.profile_image_url, length: 320),
"name" => user.name,

View file

@ -2,9 +2,16 @@ require "rails_helper"
RSpec.describe "Users", type: :request do
describe "GET /users" do
let(:user) { create(:user) }
let(:user) { create(:user, username: "Sloan") }
let!(:suggested_users_list) { %w[eeyore] }
let!(:suggested_user) { create(:user, name: "Eeyore", username: "eeyore", summary: "I am always sad :(") }
let!(:suggested_user_profile) do
create(
:profile,
user: create(:user, :without_profile, username: "eeyore", name: "Eeyore"),
summary: "I am always sad",
)
end
let!(:suggested_user) { suggested_user_profile.user }
before do
allow(SiteConfig).to receive(:suggested_users).and_return(suggested_users_list)

View file

@ -17,9 +17,11 @@ RSpec.describe ProfileFields::FieldDefinition, type: :service do
test_class.call
end.to change(ProfileField, :count).by(2)
expect(ProfileField.pluck(:label)).to match_array(["Test 1", "Test 2"])
labels = ProfileField.pluck(:label)
expect(labels).to include("Test 1")
expect(labels).to include("Test 2")
group = ProfileFieldGroup.find_by(name: "DSL Test")
expect(ProfileField.pluck(:profile_field_group_id)).to match_array([group.id, group.id])
expect(ProfileField.pluck(:profile_field_group_id).count(group.id)).to eq(2)
end
end
end

View file

@ -1,38 +1,37 @@
require "rails_helper"
RSpec.describe ProfileFields::ImportFromCsv do
# Importing is slow, so we only do it once and then clean up after outselves.
# rubocop:disable RSpec/BeforeAfterAll
before(:all) { described_class.call(file_fixture("profile_fields.csv")) }
after(:all) { ProfileField.destroy_all }
# rubocop:enable RSpec/BeforeAfterAll
it "ignores empty lines" do
expect(ProfileField.count).to eq 3
expect do
described_class.call(file_fixture("profile_fields.csv"))
end.to change(ProfileField, :count).by(3)
end
it "handles missing descriptions", :aggregate_failures do
field = ProfileField.find_by!(label: "Name")
expect(field.input_type).to eq "text_field"
expect(field.placeholder_text).to eq "John Doe"
expect(field.description).to be_nil
expect(field.profile_field_group.name).to eq "Basic"
end
context "when missing attributes" do
before { described_class.call(file_fixture("profile_fields.csv")) }
it "handles missing placeholder_texts", :aggregate_failures do
field = ProfileField.find_by!(label: "Skills/Languages")
expect(field.input_type).to eq "text_area"
expect(field.placeholder_text).to be_nil
expect(field.description).to eq "Programming languages"
expect(field.profile_field_group.name).to eq "Coding"
end
it "handles missing descriptions", :aggregate_failures do
field = ProfileField.find_by!(label: "Test name")
expect(field.input_type).to eq "text_field"
expect(field.placeholder_text).to eq "John Doe"
expect(field.description).to be_nil
expect(field.profile_field_group.name).to eq "Basic"
end
it "handles commas in correctly quoted fields", :aggregate_failures do
field = ProfileField.find_by!(label: "Color")
expect(field.input_type).to eq "color_field"
expect(field.placeholder_text).to eq "#000000"
expect(field.description).to eq "Used for backgrounds, borders etc."
expect(field.profile_field_group.name).to eq "Branding"
it "handles missing placeholder_texts", :aggregate_failures do
field = ProfileField.find_by!(label: "Test languages")
expect(field.input_type).to eq "text_area"
expect(field.placeholder_text).to be_nil
expect(field.description).to eq "Programming languages"
expect(field.profile_field_group.name).to eq "Coding"
end
it "handles commas in correctly quoted fields", :aggregate_failures do
field = ProfileField.find_by!(label: "Test color")
expect(field.input_type).to eq "color_field"
expect(field.placeholder_text).to eq "#000000"
expect(field.description).to eq "Used for backgrounds, borders etc."
expect(field.profile_field_group.name).to eq "Branding"
end
end
end

View file

@ -1,28 +1,23 @@
require "rails_helper"
RSpec.describe Profiles::Update, type: :service do
def sidekiq_assert_resave_article_worker(user, &block)
sidekiq_assert_enqueued_with(
job: Users::ResaveArticlesWorker,
args: [user.id],
queue: "medium_priority",
&block
)
end
let(:profile) do
create(:profile, data: { name: "Sloan Doe", looking_for_work: true, removed: "Bla" })
create(:profile, data: { looking_for_work: true, removed: "Bla" })
end
let(:user) { profile.user }
before do
create(:profile_field, label: "Name", input_type: :text_field)
create(:profile_field, label: "Looking for work", input_type: :check_box)
Profile.refresh_attributes!
end
it "only tries to sync changes to User if the profile update succeeds" do
service = described_class.new(user, profile: {}, user: {})
allow(service).to receive(:update_profile).and_return(false)
expect(service).not_to receive(:sync_to_user) # rubocop:disable RSpec/MessageSpies
service.call
end
it "correctly typecasts new attributes", :aggregate_failures do
described_class.call(user, profile: { name: 123, looking_for_work: "false" })
expect(profile.name).to eq "123"
described_class.call(user, profile: { location: 123, looking_for_work: "false" })
expect(user.location).to eq "123"
expect(profile.looking_for_work).to be false
end
@ -35,8 +30,7 @@ RSpec.describe Profiles::Update, type: :service do
it "propagates changes to user", :agregate_failures do
new_name = "Sloan Doe"
described_class.call(user, profile: {}, user: { name: new_name })
expect(profile.name).to eq new_name
expect(profile.user[:name]).to eq new_name
expect(profile.user.name).to eq new_name
end
it "sets custom attributes for the user" do
@ -49,7 +43,7 @@ RSpec.describe Profiles::Update, type: :service do
it "updates the profile_updated_at column" do
expect do
described_class.call(user, profile: { name: 123, looking_for_work: "false" })
described_class.call(user, profile: { looking_for_work: "false" })
end.to change { user.reload.profile_updated_at }
end
@ -58,7 +52,7 @@ RSpec.describe Profiles::Update, type: :service do
service = described_class.call(user, profile: {}, user: { profile_image: profile_image })
expect(service.success?).to be false
expect(service.error_message).to eq "Profile image File size should be less than 2 MB"
expect(service.errors_as_sentence).to eq "Profile image File size should be less than 2 MB"
end
it "returns an error if Profile image is not a file" do
@ -66,7 +60,7 @@ RSpec.describe Profiles::Update, type: :service do
service = described_class.call(user, profile: {}, user: { profile_image: profile_image })
expect(service.success?).to be false
expect(service.error_message).to eq "invalid file type. Please upload a valid image."
expect(service.errors_as_sentence).to eq "invalid file type. Please upload a valid image."
end
it "returns an error if Profile image file name is too long" do
@ -75,15 +69,62 @@ RSpec.describe Profiles::Update, type: :service do
service = described_class.call(user, profile: {}, user: { profile_image: profile_image })
expect(service.success?).to be false
expect(service.error_message).to eq "filename too long - the max is 250 characters."
expect(service.errors_as_sentence).to eq "filename too long - the max is 250 characters."
end
it "works correctly if a profile field does not exist for the User model" do
profile_field = create(:profile_field, label: "No User Test")
Profile.refresh_attributes!
context "when conditionally resaving articles" do
it "enqueues resave articles job when changing username" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, user: { username: "#{user.username}_changed" })
end
end
expect do
described_class.call(user, profile: { profile_field.attribute_name => "Test" }, user: {})
end.to change { profile.reload.public_send(profile_field.attribute_name) }
it "enqueues resave articles job when changing profile_image" do
profile_image = fixture_file_upload("files/800x600.jpg")
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, user: { profile_image: profile_image })
end
end
it "enqueues resave articles job when changing name" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, user: { name: "#{user.name} changed" })
end
end
it "enqueues resave articles job when changing summary" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, profile: { summary: "#{user.summary} changed" })
end
end
it "enqueues resave articles job when changing bg_color_hex" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, profile: { brand_color1: "#12345F" })
end
end
it "enqueues resave articles job when changing text_color_hex" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, profile: { brand_color2: "#12345F" })
end
end
Authentication::Providers.username_fields.each do |username_field|
it "enqueues resave articles job when changing #{username_field}" do
sidekiq_assert_resave_article_worker(user) do
described_class.call(user, user: { username_field => "greatnewusername" })
end
end
it "doesn't enqueue resave articles job when changing #{username_field} for a banned user" do
banned_user = create(:user, :banned)
expect do
described_class.call(banned_user, user: { username_field => "greatnewusername" })
end.not_to change(Users::ResaveArticlesWorker.jobs, :size)
end
end
end
end

View file

@ -2,41 +2,45 @@ require "rails_helper"
RSpec.describe "Admin manages profile fields", type: :system do
let(:admin) { create(:user, :super_admin) }
let!(:profile_field_group) { create(:profile_field_group, name: "Delete Me!") }
let!(:profile_field) { create(:profile_field, profile_field_group: profile_field_group, label: "Delete Me Too!") }
let!(:profile_field_group) { create(:profile_field_group, name: "Delete Me") }
let(:label) { "Delete Me Too" }
before do
FeatureFlag.enable(:profile_admin)
create(:profile_field, profile_field_group: profile_field_group, label: label)
Profile.refresh_attributes!
allow(FeatureFlag).to receive(:enabled?).with(:profile_admin).and_return(true)
sign_in admin
visit admin_profile_fields_path
end
after do
FeatureFlag.disable(:profile_admin)
end
it "adds a profile group" do
click_on "Add group"
first("input#profile_field_group_name", visible: true).set("Example group")
click_button "Add group"
find("#add-group-modal #profile_field_group_name").set("Example group")
click_on "Create Profile field group"
expect(page).to have_text("Successfully created group: Example group")
end
it "adds a profile field" do
click_on "Add Field"
first("input#profile_field_label", visible: true).set("Example field")
click_on "Create Profile field"
group_name = profile_field_group.name.gsub(/\s+/, "_")
within(find("#profile-field-group-#{profile_field_group.id}")) do
click_button("Add Field")
input = find("#add-#{group_name}-profile-field-modal #profile_field_label")
input.set("Example field")
click_on "Create Profile field"
end
expect(page).to have_text("Profile field Example field created")
end
it "deletes a profile_field_group" do
click_button "Delete Group"
find("#profile-field-group-#{profile_field_group.id}").click_button("Delete Group")
expect(page).to have_text("Group #{profile_field_group.name} deleted")
end
it "deletes a profile_field" do
profile_field = ProfileField.find_by(label: label)
expect(page).to have_text(profile_field.label.to_s)
click_button "Delete Profile Field"
expect(page).to have_text("Profile field #{profile_field.label} deleted")
find("#profile-field-#{profile_field.id}").click_button("Delete Profile Field")
expect(page).to have_text("Profile field #{label} deleted")
end
end

View file

@ -5,8 +5,6 @@ RSpec.describe "Looking For Work", type: :system do
before do
user.follow(create(:tag, name: "hiring"))
create(:profile_field, label: "Looking for work", input_type: :check_box)
Profile.refresh_attributes!
end
it "user selects looking for work and autofollows hiring tag", js: true do

View file

@ -33,7 +33,7 @@ RSpec.describe Moderator::BanishUserWorker, type: :worker do
end
it "reassigns profile info" do
expect(user.currently_hacking_on).to eq("")
expect(user.currently_hacking_on).to be_blank
end
it "creates an entry in the BanishedUsers table" do