From 30119325c4518774964bf0e3a999214d907e39f5 Mon Sep 17 00:00:00 2001 From: Goran Gjuroski Date: Fri, 24 Jan 2025 17:17:59 +0100 Subject: [PATCH] Sociable --- ...brands-solid.svg => bsky-brands-solid.svg} | 0 ...solid.svg => speakerdeck-brands-solid.svg} | 0 .../fontawesome/website-brands-solid.svg | 1 + app/avo/resources/speaker.rb | 1 + app/controllers/social_profiles_controller.rb | 74 ++++++++++ .../speakers/social_profiles_controller.rb | 9 ++ app/controllers/speakers_controller.rb | 6 - app/models/concerns/sociable.rb | 16 ++- app/models/concerns/suggestable.rb | 7 +- app/models/event.rb | 2 +- app/models/organisation.rb | 1 + app/models/social_profile.rb | 55 ++++--- app/models/speaker/profiles.rb | 11 +- app/views/events/_header.html.erb | 12 +- app/views/shared/_new_profile.html.erb | 10 ++ app/views/social_profiles/_form.html.erb | 13 ++ .../social_profiles/create.turbo_stream.erb | 18 +++ app/views/social_profiles/edit.html.erb | 3 + app/views/social_profiles/new.html.erb | 3 + .../social_profiles/update.turbo_stream.erb | 11 ++ app/views/speakers/_form.html.erb | 30 ---- app/views/speakers/_header_content.html.erb | 14 +- app/views/speakers/_socials.html.erb | 36 +---- app/views/speakers/edit.html.erb | 16 ++- config/initializers/field_with_errors.rb | 3 + config/routes.rb | 9 +- .../20250123120534_create_social_profiles.rb | 37 ++++- db/schema.rb | 8 +- tailwind.config.js | 14 +- .../social_profiles_controller_test.rb | 136 ++++++++++++++++++ test/controllers/speakers_controller_test.rb | 8 +- test/fixtures/organisations.yml | 5 - test/fixtures/social_profiles.yml | 54 +++++-- test/fixtures/speakers.yml | 12 -- test/models/event_test.rb | 7 +- test/models/social_profile_test.rb | 75 +++++++++- test/models/speaker_test.rb | 64 ++------- 37 files changed, 569 insertions(+), 212 deletions(-) rename app/assets/images/icons/fontawesome/{bluesky-brands-solid.svg => bsky-brands-solid.svg} (100%) rename app/assets/images/icons/fontawesome/{speaker-deck-brands-solid.svg => speakerdeck-brands-solid.svg} (100%) create mode 100644 app/assets/images/icons/fontawesome/website-brands-solid.svg create mode 100644 app/controllers/social_profiles_controller.rb create mode 100644 app/controllers/speakers/social_profiles_controller.rb create mode 100644 app/views/shared/_new_profile.html.erb create mode 100644 app/views/social_profiles/_form.html.erb create mode 100644 app/views/social_profiles/create.turbo_stream.erb create mode 100644 app/views/social_profiles/edit.html.erb create mode 100644 app/views/social_profiles/new.html.erb create mode 100644 app/views/social_profiles/update.turbo_stream.erb create mode 100644 config/initializers/field_with_errors.rb create mode 100644 test/controllers/social_profiles_controller_test.rb diff --git a/app/assets/images/icons/fontawesome/bluesky-brands-solid.svg b/app/assets/images/icons/fontawesome/bsky-brands-solid.svg similarity index 100% rename from app/assets/images/icons/fontawesome/bluesky-brands-solid.svg rename to app/assets/images/icons/fontawesome/bsky-brands-solid.svg diff --git a/app/assets/images/icons/fontawesome/speaker-deck-brands-solid.svg b/app/assets/images/icons/fontawesome/speakerdeck-brands-solid.svg similarity index 100% rename from app/assets/images/icons/fontawesome/speaker-deck-brands-solid.svg rename to app/assets/images/icons/fontawesome/speakerdeck-brands-solid.svg diff --git a/app/assets/images/icons/fontawesome/website-brands-solid.svg b/app/assets/images/icons/fontawesome/website-brands-solid.svg new file mode 100644 index 000000000..fd2fb694c --- /dev/null +++ b/app/assets/images/icons/fontawesome/website-brands-solid.svg @@ -0,0 +1 @@ + diff --git a/app/avo/resources/speaker.rb b/app/avo/resources/speaker.rb index adc0a5a6f..29372e22e 100644 --- a/app/avo/resources/speaker.rb +++ b/app/avo/resources/speaker.rb @@ -17,6 +17,7 @@ class Avo::Resources::Speaker < Avo::BaseResource def fields field :id, as: :id, link_to_record: true field :name, as: :text, link_to_record: true, sortable: true + field :github, as: :text field :bio, as: :textarea, hide_on: :index field :slug, as: :text, hide_on: :index field :talks_count, as: :number, sortable: true diff --git a/app/controllers/social_profiles_controller.rb b/app/controllers/social_profiles_controller.rb new file mode 100644 index 000000000..f4fd1e35d --- /dev/null +++ b/app/controllers/social_profiles_controller.rb @@ -0,0 +1,74 @@ +class SocialProfilesController < ApplicationController + skip_before_action :authenticate_user!, only: [:edit, :update] + before_action :check_ownership, only: [:new, :create] + before_action :check_provider, only: [:new] + before_action :set_social_profile, only: [:edit, :update] + + def new + @social_profile = @sociable.social_profiles.new(provider: params[:provider]) + end + + def create + @social_profile = @sociable.social_profiles.new(social_profile_params) + + begin + ActiveRecord::Base.transaction do + @social_profile.save! + @suggestion = @social_profile.create_suggestion_from( + params: @social_profile.attributes.slice("provider", "value"), + new_record: true + ) + end + rescue ActiveRecord::RecordInvalid + end + + respond_to do |format| + if @suggestion&.persisted? + flash[:notice] = "Saved" + format.turbo_stream + else + format.turbo_stream { render :create, status: :unprocessable_entity } + end + end + end + + def edit + end + + def update + begin + @suggestion = @social_profile.create_suggestion_from(params: social_profile_params, user: Current.user) + rescue ActiveRecord::RecordInvalid + end + + respond_to do |format| + if @suggestion&.persisted? + flash[:notice] = "Saved" + format.turbo_stream + else + format.turbo_stream { render :update, status: :unprocessable_entity } + end + end + end + + private + + def social_profile_params + params.require(:social_profile).permit( + :provider, + :value + ) + end + + def set_social_profile + @social_profile ||= SocialProfile.find(params[:id]) + end + + def check_provider + raise StandardError, "Invalid Social Provider" unless SocialProfile::PROVIDERS.include?(params[:provider]) + end + + def check_ownership + head :forbidden unless @sociable.managed_by?(Current.user) + end +end diff --git a/app/controllers/speakers/social_profiles_controller.rb b/app/controllers/speakers/social_profiles_controller.rb new file mode 100644 index 000000000..ffebb19f8 --- /dev/null +++ b/app/controllers/speakers/social_profiles_controller.rb @@ -0,0 +1,9 @@ +class Speakers::SocialProfilesController < ::SocialProfilesController + prepend_before_action :set_sociable + + private + + def set_sociable + @sociable ||= Speaker.find_by!(slug: params[:speaker_slug]) + end +end diff --git a/app/controllers/speakers_controller.rb b/app/controllers/speakers_controller.rb index f6a39db74..f24061d0b 100644 --- a/app/controllers/speakers_controller.rb +++ b/app/controllers/speakers_controller.rb @@ -69,13 +69,7 @@ def speaker_params params.require(:speaker).permit( :name, :github, - :twitter, - :bsky, - :linkedin, - :mastodon, :bio, - :website, - :speakerdeck, :pronouns_type, :pronouns, :slug diff --git a/app/models/concerns/sociable.rb b/app/models/concerns/sociable.rb index 6851b42ad..db0256697 100644 --- a/app/models/concerns/sociable.rb +++ b/app/models/concerns/sociable.rb @@ -2,6 +2,20 @@ module Sociable extend ActiveSupport::Concern included do - has_many :social_profiles, as: :sociable, dependent: :destroy + has_many :social_profiles, -> { order(:created_at) }, as: :sociable, dependent: :destroy + end + + SocialProfile::PROVIDERS.each do |method| + define_method(method) do + social_profiles.send(method).first&.value + end + + define_method(:"build_#{method}") do |value| + social_profiles.build(provider: method, value:) + end + + define_method(:"create_#{method}") do |value| + social_profiles.create(provider: method, value:) + end end end diff --git a/app/models/concerns/suggestable.rb b/app/models/concerns/suggestable.rb index 9e934a698..5e05fea7a 100644 --- a/app/models/concerns/suggestable.rb +++ b/app/models/concerns/suggestable.rb @@ -5,8 +5,11 @@ module Suggestable has_many :suggestions, as: :suggestable, dependent: :destroy end - def create_suggestion_from(params:, user: Current.user) - suggestions.create(content: select_differences_for(params), suggested_by_id: user&.id).tap do |suggestion| + # NOTE: validate before saving + def create_suggestion_from(params:, user: Current.user, new_record: false) + params = select_differences_for(params) unless new_record + + suggestions.create(content: params, suggested_by_id: user&.id).tap do |suggestion| suggestion.approved!(approver: user) if managed_by?(user) end end diff --git a/app/models/event.rb b/app/models/event.rb index 812f72bd8..421afce42 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -290,6 +290,6 @@ def year end def website - self[:website].presence || organisation.website + social_profiles.detect { |sp| sp.website? }&.value || organisation.website end end diff --git a/app/models/organisation.rb b/app/models/organisation.rb index e3333983b..d2647b485 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -28,6 +28,7 @@ class Organisation < ApplicationRecord include Sluggable include Suggestable + include Sociable include ActionView::Helpers::TextHelper diff --git a/app/models/social_profile.rb b/app/models/social_profile.rb index 174cf8be4..fdab01f6b 100644 --- a/app/models/social_profile.rb +++ b/app/models/social_profile.rb @@ -3,39 +3,39 @@ # Table name: social_profiles # # id :integer not null, primary key -# provider :integer -# sociable_type :string indexed => [sociable_id] -# value :string +# provider :string not null +# sociable_type :string not null, indexed => [sociable_id] +# value :string not null # created_at :datetime not null # updated_at :datetime not null -# sociable_id :integer indexed => [sociable_type] +# sociable_id :integer not null, indexed => [sociable_type] # # Indexes # # index_social_profiles_on_sociable (sociable_type,sociable_id) # class SocialProfile < ApplicationRecord + include Suggestable + PROVIDERS = %w[twitter linkedin bsky mastodon speakerdeck website] + belongs_to :sociable, polymorphic: true - enum :provider, { - github: 0, - twitter: 1, - linkedin: 2, - bsky: 3, - mastadon: 4 - }, - suffix: true, - validate: {presence: true} - - before_save do - self.value = self.class.normalize_value_for(provider.to_sym, value) + enum :provider, PROVIDERS.index_by(&:itself), validate: {presence: true} + + after_initialize do + self.value = self.class.normalize_value_for(provider.to_sym, value) if provider.present? end + validates :provider, presence: true + validates :value, presence: true, uniqueness: {scope: :provider} + + scope :excluding_provider, ->(provider) { where.not(provider:) } + # normalizes - normalizes :github, with: ->(value) { value.gsub(/^(?:https?:\/\/)?(?:www\.)?github\.com\//, "").gsub(/^@/, "") } normalizes :twitter, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:x\.com|twitter\.com)/}, "").gsub(/@/, "") } normalizes :linkedin, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:linkedin\.com/in)/}, "") } - normalizes :bsky, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:[^\/]+\.com)/}, "").gsub(/@/, "") } + normalizes :bsky, with: ->(value) { value.gsub(%r{https?://(?:www\.)?(?:x\.com|bsky\.app/profile)/}, "").gsub(/@/, "") } + normalizes :speakerdeck, with: ->(value) { value.gsub(/^(?:https?:\/\/)?(?:www\.)?speakerdeck\.com\//, "").gsub(/^@/, "") } normalizes :mastodon, with: ->(value) { return value if value&.match?(URI::DEFAULT_PARSER.make_regexp) return "" unless value.count("@") == 2 @@ -44,4 +44,23 @@ class SocialProfile < ApplicationRecord "https://#{instance}/@#{handle}" } + + def url + case provider.to_sym + when :twitter, :speakerdeck + "https://#{provider}.com/#{value}" + when :linkedin + "https://linkedin.com/in/#{value}" + when :bsky + "https://bsky.app/profile/#{value}" + else + value + end + end + + private + + def managed_by?(visiting_user) + sociable.managed_by?(visiting_user) + end end diff --git a/app/models/speaker/profiles.rb b/app/models/speaker/profiles.rb index d6ace8daf..d260de7b5 100644 --- a/app/models/speaker/profiles.rb +++ b/app/models/speaker/profiles.rb @@ -15,13 +15,14 @@ def enhance_all_later socials = github_client.social_accounts(speaker.github) links = socials.pluck(:provider, :url).to_h + speaker.build_twitter(links["twitter"]) if links["twitter"].present? + speaker.build_mastodon(links["mastodon"]) if links["mastodon"].present? + speaker.build_bsky(links["bluesky"]) if links["bluesky"].present? + speaker.build_linkedin(links["linkedin"]) if links["linkedin"].present? + speaker.build_website(profile.blog) if profile.blog.present? + speaker.update!( - twitter: speaker.twitter.presence || links["twitter"] || "", - mastodon: speaker.mastodon.presence || links["mastodon"] || "", - bsky: speaker.bsky.presence || links["bluesky"] || "", - linkedin: speaker.linkedin.presence || links["linkedin"] || "", bio: speaker.bio.presence || profile.bio || "", - website: speaker.website.presence || profile.blog || "", github_metadata: { profile: JSON.parse(profile.body), socials: JSON.parse(socials.body) diff --git a/app/views/events/_header.html.erb b/app/views/events/_header.html.erb index 4665ab816..0a574dc32 100644 --- a/app/views/events/_header.html.erb +++ b/app/views/events/_header.html.erb @@ -14,18 +14,20 @@
-
-
+
+
<%= image_tag image_path(event.avatar_image_path), class: "rounded-full border border-[#D9DFE3] size-24 md:size-36", alt: "#{event.name} Avatar", style: "view-transition-name: avatar", loading: :lazy %> -
-

<%= event.name %>

+
+

<%= event.name %>

<%= event.location %> • <%= event.formatted_dates %>

- <%= external_link_to event.website.gsub(%r{^https?://}, "").gsub(%r{/$}, ""), event.website %> + <% if event.website.present? %> + <%= external_link_to event.website.gsub(%r{^https?://}, "").gsub(%r{/$}, ""), event.website %> + <% end %>
diff --git a/app/views/shared/_new_profile.html.erb b/app/views/shared/_new_profile.html.erb new file mode 100644 index 000000000..2c7377cbf --- /dev/null +++ b/app/views/shared/_new_profile.html.erb @@ -0,0 +1,10 @@ +<%= turbo_frame_tag social_profile do %> +
+ Add + <% SocialProfile::PROVIDERS.each do |provider| %> + <%= ui_button url: new_polymorphic_path([sociable, social_profile], provider:), kind: :circle, size: :sm, class: "hover:bg-#{provider} hover:fill-white border-base-200" do %> + <%= fab(provider, size: :md) %> + <% end %> + <% end %> +
+<% end %> diff --git a/app/views/social_profiles/_form.html.erb b/app/views/social_profiles/_form.html.erb new file mode 100644 index 000000000..0548c8663 --- /dev/null +++ b/app/views/social_profiles/_form.html.erb @@ -0,0 +1,13 @@ +<%= turbo_frame_tag social_profile do %> + <%= form_with(model: social_profile, url: url, class: "flex flex-col") do |form| %> + <%= form.label :value, "#{form.object.provider.humanize} (URL/Handle/Username)" %> +
+ <%= social_profile.errors["value"] ? social_profile.errors["value"].first : "" %> +
+ <%= form.hidden_field :provider, class: "input input-bordered w-full" %> + <%= form.text_field :value, class: "input input-bordered w-full" %> + <%= ui_button "Save", type: :submit %> +
+
+ <% end %> +<% end %> diff --git a/app/views/social_profiles/create.turbo_stream.erb b/app/views/social_profiles/create.turbo_stream.erb new file mode 100644 index 000000000..90d6cb86f --- /dev/null +++ b/app/views/social_profiles/create.turbo_stream.erb @@ -0,0 +1,18 @@ +<%= turbo_stream.update "flashes" do %> + <%= render "shared/flashes" %> +<% end %> + +<% if @social_profile.persisted? %> + <%= turbo_stream.update "socials" do %> + <%= render "speakers/socials", speaker: @social_profile.sociable %> + <% end %> + + <%= turbo_stream.append "social-profiles" do %> + <%= render "social_profiles/form", social_profile: @social_profile, url: social_profile_path(@social_profile) %> + <%= render "shared/new_profile", sociable: @social_profile.sociable, social_profile: SocialProfile.new %> + <% end %> +<% else %> + <%= turbo_stream.replace @social_profile do %> + <%= render "social_profiles/form", social_profile: @social_profile, url: speaker_social_profiles_path(@social_profile.sociable, @social_profile) %> + <% end %> +<% end %> diff --git a/app/views/social_profiles/edit.html.erb b/app/views/social_profiles/edit.html.erb new file mode 100644 index 000000000..05d0250b8 --- /dev/null +++ b/app/views/social_profiles/edit.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag @social_profile do %> + <%= render "form", social_profile: @social_profile, url: social_profile_path(@social_profile) %> +<% end %> diff --git a/app/views/social_profiles/new.html.erb b/app/views/social_profiles/new.html.erb new file mode 100644 index 000000000..e1135aab1 --- /dev/null +++ b/app/views/social_profiles/new.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag @social_profile do %> + <%= render "form", social_profile: @social_profile, url: speaker_social_profiles_path(@social_profile.sociable, @social_profile) %> +<% end %> diff --git a/app/views/social_profiles/update.turbo_stream.erb b/app/views/social_profiles/update.turbo_stream.erb new file mode 100644 index 000000000..5cc4869a7 --- /dev/null +++ b/app/views/social_profiles/update.turbo_stream.erb @@ -0,0 +1,11 @@ +<%= turbo_stream.update "flashes" do %> + <%= render "shared/flashes" %> +<% end %> + +<%= turbo_stream.update "socials" do %> + <%= render "speakers/socials", speaker: @social_profile.sociable %> +<% end %> + +<%= turbo_stream.replace @social_profile do %> + <%= render "social_profiles/form", social_profile: @social_profile, url: social_profile_path(@social_profile) %> +<% end %> diff --git a/app/views/speakers/_form.html.erb b/app/views/speakers/_form.html.erb index 5e9f7c78e..3c62c6d7a 100644 --- a/app/views/speakers/_form.html.erb +++ b/app/views/speakers/_form.html.erb @@ -9,31 +9,6 @@ <%= form.text_field :github, class: "input input-bordered w-full" %>
-
- <%= form.label :twitter, "Twitter/X (URL or handle)" %> - <%= form.text_field :twitter, class: "input input-bordered w-full" %> -
- -
- <%= form.label :mastodon, "Mastodon (Full URL/handle)" %> - <%= form.text_field :mastodon, class: "input input-bordered w-full" %> -
- -
- <%= form.label :linkedin, "LinekdIn (URL or slug)" %> - <%= form.text_field :linkedin, class: "input input-bordered w-full" %> -
- -
- <%= form.label :bsky, "Bluesky (URL or handle)" %> - <%= form.text_field :bsky, class: "input input-bordered w-full" %> -
- -
- <%= form.label :speakerdeck, "Speakerdeck" %> - <%= form.text_field :speakerdeck, class: "input input-bordered w-full" %> -
-
<%= form.label :bio %> <%= form.text_area :bio, rows: 4, class: "textarea textarea-bordered w-full" %> @@ -47,11 +22,6 @@
-
- <%= form.label :website %> - <%= form.text_field :website, class: "input input-bordered w-full" %> -
-
<%= ui_button "Suggest modifications", type: :submit %> <%= ui_button "Cancel", data: {action: "click->modal#close"}, role: :button, kind: :ghost %> diff --git a/app/views/speakers/_header_content.html.erb b/app/views/speakers/_header_content.html.erb index 7079bc87d..b7fa3e49a 100644 --- a/app/views/speakers/_header_content.html.erb +++ b/app/views/speakers/_header_content.html.erb @@ -1,4 +1,4 @@ -
+
<%= image_tag speaker.avatar_url(size: 200), class: "rounded-full border border-[#D9DFE3] size-24 md:size-36", @@ -8,9 +8,9 @@ loading: :lazy %>
-
-
-

<%= speaker.name %> +
+
+

<%= speaker.name %> <% if speaker.pronouns.present? && ["dont_specify", "not_specified"].exclude?(speaker.pronouns_type) %> (<%= speaker.pronouns %>) <% end %> @@ -23,11 +23,11 @@

<%= speaker.bio %>

- <% if speaker.website.present? %> - <%= external_link_to speaker.website.gsub(%r{^https?://}, ""), speaker.website, class: "text-center" %> + <% speaker.social_profiles.website.each do |profile| %> + <%= external_link_to profile.value.gsub(%r{^https?://}, ""), profile.value, class: "text-center" %> <% end %> -
+
<%= render "speakers/socials", speaker: speaker %>
diff --git a/app/views/speakers/_socials.html.erb b/app/views/speakers/_socials.html.erb index c81b86145..6b4ebb7f8 100644 --- a/app/views/speakers/_socials.html.erb +++ b/app/views/speakers/_socials.html.erb @@ -1,38 +1,8 @@
- <% if speaker.github.present? %> - <%= ui_button url: "https://www.github.com/#{speaker.github}", kind: :circle, size: :sm, target: "_blank", class: "hover:bg-black hover:fill-white border-base-200" do %> - <%= fab("github", size: :md) %> - <% end %> - <% end %> - - <% if speaker.twitter.present? %> - <%= ui_button url: "https://www.x.com/#{speaker.twitter}", kind: :circle, size: :sm, target: "_blank", class: "hover:bg-[#74C0FC] hover:fill-white border-base-200" do %> - <%= fab("twitter", size: :md) %> - <% end %> - <% end %> - - <% if speaker.bsky.present? %> - <%= ui_button url: "https://bsky.app/profile/#{speaker.bsky}", kind: :circle, size: :sm, target: "_blank", class: "hover:bg-[#0085FF] hover:fill-white border-base-200" do %> - <%= fab("bluesky", size: :md) %> - <% end %> - <% end %> - - <% if speaker.mastodon.present? %> - <%= ui_button url: speaker.mastodon, kind: :circle, size: :sm, target: "_blank", class: "hover:bg-[#6364FF] hover:fill-white border-base-200", data: {tip: speaker.mastodon} do %> - <%= fab("mastodon", size: :md) %> - <% end %> - <% end %> - - <% if speaker.linkedin.present? %> - <%= ui_button url: "https://www.linkedin.com/in/#{speaker.linkedin}", kind: :circle, size: :sm, target: "_blank", class: "hover:bg-[#0A66C2] hover:fill-white border-base-200", data: {tip: speaker.linkedin} do %> - <%= fab("linkedin", size: :md) %> - <% end %> - <% end %> - - <% if speaker.speakerdeck.present? %> - <%= ui_button url: "https://speakerdeck.com/#{speaker.speakerdeck}", kind: :circle, size: :sm, target: "_blank", class: "hover:bg-[#009287] hover:fill-white border-base-200", data: {tip: speaker.speakerdeck} do %> - <%= fab("speaker-deck", size: :md) %> + <% speaker.social_profiles.excluding_provider(:website).order(:provider).each do |profile| %> + <%= ui_button url: profile.url, kind: :circle, size: :sm, target: "_blank", class: "hover:bg-#{profile.provider} hover:fill-white border-base-200" do %> + <%= fab(profile.provider, size: :md) %> <% end %> <% end %>
diff --git a/app/views/speakers/edit.html.erb b/app/views/speakers/edit.html.erb index 106b0556f..f024eefdf 100644 --- a/app/views/speakers/edit.html.erb +++ b/app/views/speakers/edit.html.erb @@ -1,7 +1,19 @@ <% content_for :canonical_url, speaker_url(@speaker) %> -
-

<%= form_title_for_user_kind(user_kind) %>

+
+

<%= form_title_for_user_kind(user_kind) %>

<%= render "form", speaker: @speaker %> + +

Social profiles

+ +
+ <% @speaker.social_profiles.each do |social_profile| %> + <%= render "social_profiles/form", social_profile:, url: social_profile_path(social_profile) %> + <% end %> + + <% if Current.user %> + <%= render "shared/new_profile", sociable: @speaker, social_profile: SocialProfile.new %> + <% end %> +
diff --git a/config/initializers/field_with_errors.rb b/config/initializers/field_with_errors.rb new file mode 100644 index 000000000..7f9647136 --- /dev/null +++ b/config/initializers/field_with_errors.rb @@ -0,0 +1,3 @@ +ActionView::Base.field_error_proc = proc do |html_tag, instance| + html_tag.html_safe +end diff --git a/config/routes.rb b/config/routes.rb index b4e4da9c1..cd828ae63 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,14 @@ end end - resources :speakers, param: :slug, only: [:index, :show, :update, :edit] + resources :speakers, param: :slug, only: [] do + resources :social_profiles, only: [:new, :create], module: :speakers + end + + resources :speakers, param: :slug, only: [:index, :show, :update, :edit] do + resources :social_profiles, only: [:edit, :update], shallow: true + end + resources :events, param: :slug, only: [:index, :show, :update, :edit] do scope module: :events do resources :schedules, only: [:index], path: "/schedule" do diff --git a/db/migrate/20250123120534_create_social_profiles.rb b/db/migrate/20250123120534_create_social_profiles.rb index 38f7a4537..6d2c5037d 100644 --- a/db/migrate/20250123120534_create_social_profiles.rb +++ b/db/migrate/20250123120534_create_social_profiles.rb @@ -1,10 +1,41 @@ class CreateSocialProfiles < ActiveRecord::Migration[8.0] def change create_table :social_profiles do |t| - t.string :value - t.integer :provider - t.belongs_to :sociable, polymorphic: true + t.string :provider, null: false + t.string :value, null: false + t.belongs_to :sociable, polymorphic: true, null: false t.timestamps end + + reversible do |dir| + dir.up do + execute <<-SQL + INSERT INTO social_profiles (sociable_id, sociable_type, value, provider, created_at, updated_at) + SELECT id AS sociable_id, 'Speaker' AS sociable_type, twitter, 'twitter' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE twitter != '' + UNION ALL + SELECT id AS sociable_id, 'Speaker' AS sociable_type, website, 'website' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE website != '' + UNION ALL + SELECT id AS sociable_id, 'Speaker' AS sociable_type, speakerdeck, 'speaker_deck' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE speakerdeck != '' + UNION ALL + SELECT id AS sociable_id, 'Speaker' AS sociable_type, mastodon, 'mastodon' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE mastodon != '' + UNION ALL + SELECT id AS sociable_id, 'Speaker' AS sociable_type, bsky, 'bsky' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE bsky != '' + UNION ALL + SELECT id AS sociable_id, 'Speaker' AS sociable_type, linkedin, 'linkedin' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM speakers WHERE linkedin != '' + SQL + + execute <<-SQL + INSERT INTO social_profiles (sociable_id, sociable_type, value, provider, created_at, updated_at) + SELECT id AS sociable_id, 'Event' AS sociable_type, website, 'website' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM events WHERE website != '' + SQL + + execute <<-SQL + INSERT INTO social_profiles (sociable_id, sociable_type, value, provider, created_at, updated_at) + SELECT id AS sociable_id, 'Organisation' AS sociable_type, website, 'website' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM organisations WHERE website != '' + UNION ALL + SELECT id AS sociable_id, 'Organisation' AS sociable_type, twitter, 'twitter' AS provider, DATETIME('now') AS created_at, DATETIME('now') AS updated_at FROM organisations WHERE twitter != '' + SQL + end + end end end diff --git a/db/schema.rb b/db/schema.rb index 6b0def2bc..d2c441d8a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -132,10 +132,10 @@ end create_table "social_profiles", force: :cascade do |t| - t.string "value" - t.integer "provider" - t.string "sociable_type" - t.integer "sociable_id" + t.string "provider", null: false + t.string "value", null: false + t.string "sociable_type", null: false + t.integer "sociable_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["sociable_type", "sociable_id"], name: "index_social_profiles_on_sociable" diff --git a/tailwind.config.js b/tailwind.config.js index c21e11418..41feec53f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -51,12 +51,24 @@ module.exports = { '0%': { opacity: '1' }, '100%': { opacity: '0' } } + }, + colors: { + github: { DEFAULT: '#FFFFFF' }, + twitter: { DEFAULT: '#74C0FC' }, + bsky: { DEFAULT: '#0085FF' }, + mastodon: { DEFAULT: '#6364FF' }, + linkedin: { DEFAULT: '#0A66C2' }, + speakerdeck: { DEFAULT: '#009287' } } } }, safelist: [ { pattern: /grid-cols-(1|2|3|4|5|6|7|8|9|10)/ }, - { pattern: /col-span-(1|2|3|4|5|6|7|8|9|10)/ } + { pattern: /col-span-(1|2|3|4|5|6|7|8|9|10)/ }, + { + pattern: /bg-(github|twitter|bsky|mastodon|linkedin|speakerdeck)/, + variants: ['hover'] + } ], daisyui: { logs: false, diff --git a/test/controllers/social_profiles_controller_test.rb b/test/controllers/social_profiles_controller_test.rb new file mode 100644 index 000000000..2411725b2 --- /dev/null +++ b/test/controllers/social_profiles_controller_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class SocialProfilesControllerTest < ActionDispatch::IntegrationTest + setup do + @social_profile = social_profiles(:speaker) + @sociable = @social_profile.sociable + @speaker = @social_profile.sociable + @owner = User.create!(email: "test@example.com", password: "Secret1*3*5*", github_handle: @speaker.github, verified: true) + @user = users(:one) + end + + test "should get new if admin" do + sign_in_as users(:admin) + + get new_polymorphic_url([@sociable, SocialProfile.new], provider: :twitter) + assert_response :success + end + + test "should get new if owner" do + sign_in_as @owner + + get new_polymorphic_url([@sociable, SocialProfile.new], provider: :twitter) + assert_response :success + end + + test "new should return forbidden if user" do + sign_in_as @user + + get new_polymorphic_url([@sociable, SocialProfile.new], provider: :twitter) + assert_response :forbidden + end + + test "new should redirect to sign_in if guest" do + get new_polymorphic_url([@sociable, SocialProfile.new], provider: :twitter) + assert_redirected_to sign_in_url + end + + test "new returns server error if invalid provider" do + sign_in_as @owner + + assert_raises StandardError do + get new_polymorphic_url([@sociable, SocialProfile.new]) + end + end + + test "shouldn't create social_profile if user" do + sign_in_as @user + + post polymorphic_url([@sociable, :social_profiles]), + params: {social_profile: {provider: :twitter, value: :john}}, + headers: {"Turbo-Frame" => "new_social_profile", "Accept" => "text/vnd.turbo-stream.html"} + + assert_response :forbidden + end + + test "should create social profile if admin" do + sign_in_as users(:admin) + + assert_difference("SocialProfile.count") do + post polymorphic_url([@sociable, :social_profiles]), + params: {social_profile: {provider: :twitter, value: :john}}, + headers: {"Turbo-Frame" => "new_social_profile", "Accept" => "text/vnd.turbo-stream.html"} + end + + assert_response :success + end + + test "should create social profile if owner" do + sign_in_as @owner + + assert_difference("SocialProfile.count") do + post polymorphic_url([@sociable, :social_profiles]), + params: {social_profile: {provider: :twitter, value: :johndoe}}, + headers: {"Turbo-Frame" => "new_social_profile", "Accept" => "text/vnd.turbo-stream.html"} + end + + assert_response :success + end + + test "shouldn't create social_profile with invalid data" do + sign_in_as @owner + + assert_no_difference("SocialProfile.count") do + post polymorphic_url([@sociable, :social_profiles]), + params: {social_profile: {provider: :twitter, value: ""}}, + headers: {"Turbo-Frame" => "new_social_profile", "Accept" => "text/vnd.turbo-stream.html"} + end + + assert_response :unprocessable_entity + end + + test "create redirects to sign_in if guest" do + post polymorphic_url([@sociable, :social_profiles]), + params: {social_profile: {provider: :twitter, value: :john}}, + headers: {"Turbo-Frame" => "new_social_profile", "Accept" => "text/vnd.turbo-stream.html"} + + assert_redirected_to sign_in_url + end + + test "should get edit" do + get edit_social_profile_url(@social_profile) + assert_response :success + end + + test "should update social_profile if admin" do + sign_in_as users(:admin) + + patch social_profile_url(@social_profile), + params: {social_profile: {provider: :twitter, value: :doe}}, + headers: {"Turbo-Frame" => "social_profile_#{@social_profile.id}", "Accept" => "text/vnd.turbo-stream.html"} + @social_profile.reload + + assert_equal "doe", @social_profile.value + assert_response :success + end + + test "should update social_profile if owner" do + sign_in_as @owner + + patch social_profile_url(@social_profile), + params: {social_profile: {provider: :twitter, value: :owner}}, + headers: {"Turbo-Frame" => "social_profile_#{@social_profile.id}", "Accept" => "text/vnd.turbo-stream.html"} + @social_profile.reload + + assert_equal "owner", @social_profile.value + assert_response :success + end + + test "shouldn't update social_profile if not admin or owner" do + patch social_profile_url(@social_profile), + params: {social_profile: {provider: :twitter, value: ""}}, + headers: {"Turbo-Frame" => "social_profile_#{@social_profile.id}", "Accept" => "text/vnd.turbo-stream.html"} + + assert_not_equal "", @social_profile.value + end +end diff --git a/test/controllers/speakers_controller_test.rb b/test/controllers/speakers_controller_test.rb index 8774d4947..8fb17e46e 100644 --- a/test/controllers/speakers_controller_test.rb +++ b/test/controllers/speakers_controller_test.rb @@ -59,7 +59,7 @@ class SpeakersControllerTest < ActionDispatch::IntegrationTest end test "should create a suggestion for speaker" do - patch speaker_url(@speaker), params: {speaker: {bio: "new bio", github: "new-github", name: "new-name", slug: "new-slug", twitter: "new-twitter", website: "new-website"}} + patch speaker_url(@speaker), params: {speaker: {bio: "new bio", github: "new-github", name: "new-name", slug: "new-slug"}} @speaker.reload @@ -70,7 +70,7 @@ class SpeakersControllerTest < ActionDispatch::IntegrationTest test "admin can update directly the speaker" do sign_in_as users(:admin) - patch speaker_url(@speaker), params: {speaker: {bio: "new bio", github: "new-github", name: "new-name", slug: "new-slug", twitter: "new-twitter", website: "new-website"}} + patch speaker_url(@speaker), params: {speaker: {bio: "new bio", github: "new-github", name: "new-name", slug: "new-slug"}} @speaker.reload @@ -88,14 +88,12 @@ class SpeakersControllerTest < ActionDispatch::IntegrationTest sign_in_as user assert_no_changes -> { @speaker.suggestions.pending.count } do - patch speaker_url(@speaker), params: {speaker: {bio: "new bio", name: "new-name", twitter: "new-twitter", website: "new-website"}} + patch speaker_url(@speaker), params: {speaker: {bio: "new bio", name: "new-name"}} end assert_redirected_to speaker_url(@speaker) assert_equal "new bio", @speaker.reload.bio assert_equal @speaker.name, "new-name" - assert_equal @speaker.twitter, "new-twitter" - assert_equal @speaker.website, "new-website" assert_equal user.id, @speaker.suggestions.last.suggested_by_id end diff --git a/test/fixtures/organisations.yml b/test/fixtures/organisations.yml index b9c54f3e3..3a4f74735 100644 --- a/test/fixtures/organisations.yml +++ b/test/fixtures/organisations.yml @@ -29,7 +29,6 @@ railsconf: name: RailsConf description: Rails conf from Ruby central - website: https://railsconf.org/ kind: 1 frequency: 1 youtube_channel_id: UCWnPjmqvljcafA0z2U1fwKQ @@ -39,7 +38,6 @@ railsconf: rubyconfth: name: RubyConfTH description: Ruby Rails Thailand - website: https://rubyconfth.com/ kind: 1 frequency: 1 youtube_channel_id: UCWnPjmqvljcsqsdafA0z2U1fwKQ @@ -49,18 +47,15 @@ rubyconfth: rails_world: name: RailsWorld description: Rails World - website: https://rubyonrails.org/world kind: 1 frequency: 1 youtube_channel_id: UC9zbLaqReIdoFfzdUbh13Nw youtube_channel_name: railsofficial slug: rails-world - twitter: rails brightonruby: name: Brighton Ruby description: Brighton Ruby - website: https://brightonruby.com/ kind: 1 frequency: 1 slug: brightonruby diff --git a/test/fixtures/social_profiles.yml b/test/fixtures/social_profiles.yml index 14a5c8ce7..b5811b360 100644 --- a/test/fixtures/social_profiles.yml +++ b/test/fixtures/social_profiles.yml @@ -9,19 +9,55 @@ # Table name: social_profiles # # id :integer not null, primary key -# provider :integer -# sociable_type :string indexed => [sociable_id] -# value :string +# provider :string not null +# sociable_type :string not null, indexed => [sociable_id] +# value :string not null # created_at :datetime not null # updated_at :datetime not null -# sociable_id :integer indexed => [sociable_type] +# sociable_id :integer not null, indexed => [sociable_type] # # Indexes # # index_social_profiles_on_sociable (sociable_type,sociable_id) # -one: {} -# column: value -# -two: {} -# column: value +speaker: + provider: "twitter" + value: "one" + sociable: one + sociable_type: Speaker + +twitter: + provider: "twitter" + value: "jim" + sociable: jim + sociable_type: Speaker + +linkedin: + provider: "linkedin" + value: "jim" + sociable: jim + sociable_type: Speaker + +bsky: + provider: "bsky" + value: "@jim" + sociable: jim + sociable_type: Speaker + +mastodon: + provider: "mastodon" + value: "@jim@ruby.social" + sociable: jim + sociable_type: Speaker + +speakerdeck: + provider: "speakerdeck" + value: "jim" + sociable: jim + sociable_type: Speaker + +website: + provider: "website" + value: "https://jim.com" + sociable: jim + sociable_type: Speaker diff --git a/test/fixtures/speakers.yml b/test/fixtures/speakers.yml index 4af94988d..d0d0f5ff5 100644 --- a/test/fixtures/speakers.yml +++ b/test/fixtures/speakers.yml @@ -37,43 +37,31 @@ one: name: Obie Fernandez - twitter: obie github: obie bio: Consultant, 25+ year veteran of professional software development, serial entrepreneur, and best-selling author. Currently serving as Founder & CEO of RCRDSHP - website: http://obiefernandez.com slug: obie-fernandez two: name: Ezra Zygmuntowicz - twitter: "" github: ezmobius bio: "" - website: http://stuffstr.com slug: ezra-zygmuntowicz jim: name: Jim Weirich - twitter: "" github: jimweirich bio: "" - website: http://onestepback.org slug: jim-weirich patrick: name: Patrick Leonard - twitter: pj_leonard github: pjleonard37 bio: Maps! - website: https://www.patrick-leonard.com slug: patrick-leonard michael: name: Michael Hartl - twitter: mhartl github: mhartl bio: Author of the @railstutorial, founder of @learnenough, @softcover, and Tau Day. - website: https://www.michaelhartl.com/ slug: michael-hartl yaroslav: name: Yaroslav Shmarov - twitter: yarotheslav github: yshmarov bio: "Source code for My Ruby on Rails tutorials: @corsego" - website: https://superails.com/ slug: yaroslav-shmarov diff --git a/test/models/event_test.rb b/test/models/event_test.rb index be3510221..2b54cdbf0 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -3,7 +3,7 @@ class EventTest < ActiveSupport::TestCase setup do @organisation = organisations(:railsconf) - @organisation.update(website: "https://railsconf.org") + @organisation.create_website("https://railsconf.org") end test "validates the country code " do @@ -17,12 +17,13 @@ class EventTest < ActiveSupport::TestCase end test "returns event website if present" do - event = Event.new(name: "test", organisation: @organisation, website: "https://event-website.com") + event = Event.new(name: "test", organisation: @organisation) + event.build_website("https://event-website.com") assert_equal "https://event-website.com", event.website end test "returns organisation website if event website is not present" do - event = Event.new(name: "test", organisation: @organisation, website: nil) + event = Event.new(name: "test", organisation: @organisation) assert_equal "https://railsconf.org", event.website end end diff --git a/test/models/social_profile_test.rb b/test/models/social_profile_test.rb index 2d7da1b8f..1ab30caf9 100644 --- a/test/models/social_profile_test.rb +++ b/test/models/social_profile_test.rb @@ -1,7 +1,76 @@ require "test_helper" class SocialProfileTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "sociable association" do + speaker = Speaker.create(name: "jogn") + social_profile = SocialProfile.create(sociable: speaker, provider: :twitter, value: "john") + + assert_equal social_profile.value, speaker.twitter + assert_equal social_profile.sociable, speaker + end + + test "normalizes Twitter with URL" do + assert_equal "username", SocialProfile.normalize_value_for(:twitter, "https://twitter.com/username") + end + + test "normalizes Twitter with handle" do + assert_equal "username", SocialProfile.normalize_value_for(:twitter, "username") + end + + test "normalizes X.com" do + assert_equal "username", SocialProfile.normalize_value_for(:twitter, "https://x.com/username") + end + + test "normalizes bsky (bsky.social)" do + assert_equal "username.bsky.social", SocialProfile.normalize_value_for(:bsky, "https://bsky.app/profile/username.bsky.social") + end + + test "normalizes bsky with handle (bsky.social)" do + assert_equal "username.bsky.social", SocialProfile.normalize_value_for(:bsky, "username.bsky.social") + end + + test "normalizes bsky (custom domain)" do + assert_equal "username.dev", SocialProfile.normalize_value_for(:bsky, "https://bsky.app/profile/username.dev") + end + + test "normalizes bsky with handle (custom domain)" do + assert_equal "username.dev", SocialProfile.normalize_value_for(:bsky, "username.dev") + end + + test "normaliezs mastodon (mastodon.social)" do + assert_equal "https://mastodon.social/@username", SocialProfile.normalize_value_for(:mastodon, "https://mastodon.social/@username") + end + + test "normalizes mastodon with handle (mastodon.social)" do + assert_equal "https://mastodon.social/@username", SocialProfile.normalize_value_for(:mastodon, "@username@mastodon.social") + end + + test "normalizes mastodon (ruby.social)" do + assert_equal "https://ruby.social/@username", SocialProfile.normalize_value_for(:mastodon, "https://ruby.social/@username") + end + + test "normalizes mastodon with handle (ruby.social)" do + assert_equal "https://ruby.social/@username", SocialProfile.normalize_value_for(:mastodon, "@username@ruby.social") + end + + test "normalizes linkedin with url" do + assert_equal "username", SocialProfile.normalize_value_for(:linkedin, "https://linkedin.com/in/username") + end + + test "normalizes linkedin with slug" do + assert_equal "username", SocialProfile.normalize_value_for(:linkedin, "username") + end + + test "doesnt normalize website" do + assert_equal "https://foo.bar", SocialProfile.normalize_value_for(:website, "https://foo.bar") + end + + test "generates url" do + assert_equal "https://twitter.com/jim", social_profiles(:twitter).url + assert_equal "https://linkedin.com/in/jim", social_profiles(:linkedin).url + assert_equal "https://bsky.app/profile/jim", social_profiles(:bsky).url + assert_equal "https://ruby.social/@jim", social_profiles(:mastodon).url + assert_equal "https://speakerdeck.com/jim", social_profiles(:speakerdeck).url + assert_equal "https://jim.com", social_profiles(:website).url + end end diff --git a/test/models/speaker_test.rb b/test/models/speaker_test.rb index f5c3d893e..2f945fa62 100644 --- a/test/models/speaker_test.rb +++ b/test/models/speaker_test.rb @@ -2,22 +2,26 @@ class SpeakerTest < ActiveSupport::TestCase test "valid_website_url preserve the website url if already valid" do - speaker = Speaker.new(website: "https://www.google.com") + speaker = speakers(:one) + speaker.create_website("https://www.google.com") assert_equal "https://www.google.com", speaker.valid_website_url end test "valid_website_url add https to the website if it is not present" do - speaker = Speaker.new(website: "www.google.com") + speaker = speakers(:one) + speaker.create_website("www.google.com") assert_equal "https://www.google.com", speaker.valid_website_url end test "valid_website_url convert http to https" do - speaker = Speaker.new(website: "http://www.google.com") + speaker = speakers(:one) + speaker.create_website("http://www.google.com") assert_equal "https://www.google.com", speaker.valid_website_url end test "valid_website_url returns # if website is blank" do - speaker = Speaker.new(website: "") + speaker = speakers(:one) + speaker.create_website("") assert_equal "#", speaker.valid_website_url end @@ -38,58 +42,6 @@ class SpeakerTest < ActiveSupport::TestCase assert_equal "username", Speaker.new(github: "github.com/username").github end - test "normalizes Twitter with URL" do - assert_equal "username", Speaker.new(twitter: "https://twitter.com/username").twitter - end - - test "normalizes Twitter with handle" do - assert_equal "username", Speaker.new(twitter: "username").twitter - end - - test "normalizes X.com" do - assert_equal "username", Speaker.new(twitter: "https://x.com/username").twitter - end - - test "normalizes bsky (bsky.social)" do - assert_equal "username.bsky.social", Speaker.new(bsky: "https://bsky.app/profile/username.bsky.social").bsky - end - - test "normalizes bsky with handle (bsky.social)" do - assert_equal "username.bsky.social", Speaker.new(bsky: "username.bsky.social").bsky - end - - test "normalizes bsky (custom domain)" do - assert_equal "username.dev", Speaker.new(bsky: "https://bsky.app/profile/username.dev").bsky - end - - test "normalizes bsky with handle (custom domain)" do - assert_equal "username.dev", Speaker.new(bsky: "username.dev").bsky - end - - test "normaliezs mastodon (mastodon.social)" do - assert_equal "https://mastodon.social/@username", Speaker.new(mastodon: "https://mastodon.social/@username").mastodon - end - - test "normalizes mastodon with handle (mastodon.social)" do - assert_equal "https://mastodon.social/@username", Speaker.new(mastodon: "@username@mastodon.social").mastodon - end - - test "normalizes mastodon (ruby.social)" do - assert_equal "https://ruby.social/@username", Speaker.new(mastodon: "https://ruby.social/@username").mastodon - end - - test "normalizes mastodon with handle (ruby.social)" do - assert_equal "https://ruby.social/@username", Speaker.new(mastodon: "@username@ruby.social").mastodon - end - - test "normalizes linkedin with url" do - assert_equal "username", Speaker.new(linkedin: "https://linkedin.com/in/username").linkedin - end - - test "normalizes linkedin with slug" do - assert_equal "username", Speaker.new(linkedin: "username").linkedin - end - test "assign_canonical_speaker! resets talks_count" do speaker = speakers(:yaroslav) assert speaker.talks_count.positive?