From e09e46ec99cf1cb6972cab5e61007057bb2566e4 Mon Sep 17 00:00:00 2001 From: Anna Buianova Date: Thu, 9 Jan 2020 00:14:25 +0300 Subject: [PATCH] Creating podcasts by users (#3730) [deploy] * Create podcast as a user [WIP] * Sample css for podcast form * Nicer podcasts suggesting form * Validate podcast feed_url * Validate podcast main_color_hex * Fix podcasts specs * Fix form appearance * Placeholder for podcast main_color_hex * Provide a link to suggest a podcast * Add a checkbox and a role for when a podcast is added by an owner * Prettier checkbox in the podcasts form * Added creator to podcasts * Set the podcast creator * Fix the /pod spec * Added creator information to the internal podcast page * Added cta class to the podcasts submit button * Global notice when a podcast was suggested Co-authored-by: Ben Halpern --- app/assets/stylesheets/articles.scss | 5 + app/assets/stylesheets/minimal.scss | 1 + app/assets/stylesheets/podcast-form.scss | 98 ++++++++++++++++++ app/assets/stylesheets/scaffolds.scss | 2 + app/controllers/podcasts_controller.rb | 34 ++++++ app/models/podcast.rb | 4 +- app/models/user.rb | 1 + app/views/internal/podcasts/edit.html.erb | 4 + app/views/podcast_episodes/_meta.html.erb | 2 +- app/views/podcast_episodes/_sidebar.html.erb | 4 +- app/views/podcast_episodes/admin.html.erb | 52 ---------- app/views/podcasts/_form.html.erb | 78 ++++++++++++++ app/views/podcasts/new.html.erb | 13 +++ config/routes.rb | 1 + ...20243_add_create_by_user_id_to_podcasts.rb | 7 ++ db/schema.rb | 4 +- spec/fixtures/files/podcast.png | Bin 0 -> 25033 bytes spec/models/podcast_spec.rb | 20 ++++ spec/models/user_spec.rb | 7 ++ spec/requests/podcasts/podcast_create_spec.rb | 78 ++++++++++++++ .../podcasts/podcast_episodes_index_spec.rb | 2 +- 21 files changed, 359 insertions(+), 58 deletions(-) create mode 100644 app/assets/stylesheets/podcast-form.scss create mode 100644 app/controllers/podcasts_controller.rb delete mode 100644 app/views/podcast_episodes/admin.html.erb create mode 100644 app/views/podcasts/_form.html.erb create mode 100644 app/views/podcasts/new.html.erb create mode 100644 db/migrate/20191220120243_add_create_by_user_id_to_podcasts.rb create mode 100644 spec/fixtures/files/podcast.png create mode 100644 spec/requests/podcasts/podcast_create_spec.rb diff --git a/app/assets/stylesheets/articles.scss b/app/assets/stylesheets/articles.scss index 8186126be..7bb8b1a9f 100644 --- a/app/assets/stylesheets/articles.scss +++ b/app/assets/stylesheets/articles.scss @@ -1227,6 +1227,11 @@ ); } } + .suggest-podcast { + color: var(--theme-secondary-color, #666666); + text-decoration: underline; + padding: 0 5px; + } } hr { opacity: 0.2; diff --git a/app/assets/stylesheets/minimal.scss b/app/assets/stylesheets/minimal.scss index 01083fa65..8c7a9fc7b 100644 --- a/app/assets/stylesheets/minimal.scss +++ b/app/assets/stylesheets/minimal.scss @@ -11,6 +11,7 @@ @import 'settings'; @import 'signin'; @import 'podcast-episodes-show'; +@import 'podcast-form'; @import 'more-articles'; @import 'user-profile-header'; @import 'comments'; diff --git a/app/assets/stylesheets/podcast-form.scss b/app/assets/stylesheets/podcast-form.scss new file mode 100644 index 000000000..a37665736 --- /dev/null +++ b/app/assets/stylesheets/podcast-form.scss @@ -0,0 +1,98 @@ +@import 'variables'; + +#page-content.podcasts-new, #page-content.podcasts-create { + .podcasts-form { + text-align: left; + margin: auto; + width: 100%; + position: relative; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); //Invisible black + max-width: 750px; + padding-bottom: 30px; + @media screen and (min-width: 750px) { + margin-bottom: 30px; + } + @media screen and (min-width: 950px) { + width: 100%; + margin-left: 2.3%; + margin-right: 15px; + max-width: calc(100% - 380px); + float: left; + } + @media screen and (min-width: 1120px) { + max-width: calc(100% - 640px); + } + @media screen and (min-width: 1240px) { + width: 100%; + margin-left: 2.3%; + margin-right: 15px; + max-width: 604px; + float: left; + } + } + h2 { + font-size: 1.45em; + } + .podcast-errors { + background: $red; + color: $black; + padding: 20px; + border-radius: 3px; + h2 { + color: $black; + margin: 0; + font-size: 1.3em; + } + ul { + margin-bottom: 0; + } + } + form { + font-size: 15px; + input[type='checkbox']{ + display: inline + } + input:not([type='checkbox']) { + width: 650px; + max-width: calc(100% - 30px); + padding: 12px; + font-size: 19px; + border: 1px solid rgb(222, 230, 233); + border-radius: 3px; + &[type='file'] { + border: 0px; + padding: 12px 0px; + } + &[type='submit'] { + width: 255px; + max-width: 100%; + padding: 14px 0px; + margin-top: 20px; + font-size: 1.5em; + cursor: pointer; + &.danger-button { + background: $red; + } + &:hover { + opacity: 0.88; + } + } + } + textarea { + width: 650px; + max-width: calc(100% - 30px); + padding: 12px; + font-size: 19px; + border: 1px solid rgb(222, 230, 233); + border-radius: 3px; + } + label { + display: inline-block; + vertical-align: top; + padding: 14px 0px 8px; + } + label.i_am_owner { + padding: 2px 0 2px 10px; + } + } +} diff --git a/app/assets/stylesheets/scaffolds.scss b/app/assets/stylesheets/scaffolds.scss index 08d48b7b7..2a74f6bdb 100644 --- a/app/assets/stylesheets/scaffolds.scss +++ b/app/assets/stylesheets/scaffolds.scss @@ -64,6 +64,8 @@ body { &.notifications-index, &.stories-search, &.podcast_episodes-index, + &.podcasts-new, + &.podcasts-create, &.reading_list_items-index, &.history-index, .tags-index, diff --git a/app/controllers/podcasts_controller.rb b/app/controllers/podcasts_controller.rb new file mode 100644 index 000000000..1bdb186b9 --- /dev/null +++ b/app/controllers/podcasts_controller.rb @@ -0,0 +1,34 @@ +class PodcastsController < ApplicationController + before_action :authenticate_user! + + def new + @podcast = Podcast.new + @podcasts = Podcast.available.order("title asc") + @podcast_index = true + end + + def create + @podcast = Podcast.new(podcast_params) + @podcast.creator = current_user + if @podcast.save + current_user.add_role(:podcast_admin, @podcast) if added_by_owner? + flash[:global_notice] = "Podcast suggested" + redirect_to "/pod" + else + @podcasts = Podcast.available.order("title asc") + @podcast_index = true + render :new + end + end + + private + + def added_by_owner? + params[:i_am_owner].to_i == 1 + end + + def podcast_params + allowed_params = %i[android_url image itunes_url main_color_hex overcast_url pattern_image slug soundcloud_url twitter_username website_url title feed_url description] + params.require(:podcast).permit(allowed_params) + end +end diff --git a/app/models/podcast.rb b/app/models/podcast.rb index 1699209fa..b28b588f9 100644 --- a/app/models/podcast.rb +++ b/app/models/podcast.rb @@ -2,12 +2,14 @@ class Podcast < ApplicationRecord resourcify has_many :podcast_episodes + belongs_to :creator, class_name: "User", inverse_of: :created_podcasts, foreign_key: :creator_id, optional: true mount_uploader :image, ProfileImageUploader mount_uploader :pattern_image, ProfileImageUploader validates :main_color_hex, :title, :feed_url, :image, presence: true - validates :feed_url, uniqueness: true + validates :main_color_hex, format: /\A([a-fA-F]|[0-9]){6}\Z/ + validates :feed_url, uniqueness: true, url: { schemes: %w[https http] } validates :slug, presence: true, uniqueness: true, diff --git a/app/models/user.rb b/app/models/user.rb index fb7fe4b26..45823b983 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -50,6 +50,7 @@ class User < ApplicationRecord has_many :webhook_endpoints, class_name: "Webhook::Endpoint", foreign_key: :user_id, inverse_of: :user, dependent: :delete_all has_many :user_blocks has_one :pro_membership, dependent: :destroy + has_many :created_podcasts, class_name: "Podcast", foreign_key: :creator_id, inverse_of: :creator, dependent: :nullify mount_uploader :profile_image, ProfileImageUploader diff --git a/app/views/internal/podcasts/edit.html.erb b/app/views/internal/podcasts/edit.html.erb index dfad6e35f..63747e00a 100644 --- a/app/views/internal/podcasts/edit.html.erb +++ b/app/views/internal/podcasts/edit.html.erb @@ -22,6 +22,10 @@

There are no admins for this podcast.

<% end %> + <% if @podcast.creator.present? %> +

Created by <%= link_to "@#{@podcast.creator.username}", "/#{@podcast.creator.username}" %>

+ <% end %> + <%= form_for @podcast, url: add_admin_internal_podcast_path(@podcast.id), html: { method: :post } do |f| %>
<%= f.label "Add Admin (by user_id)", for: "podcast_user_id" %> diff --git a/app/views/podcast_episodes/_meta.html.erb b/app/views/podcast_episodes/_meta.html.erb index c771b337d..60fd30636 100644 --- a/app/views/podcast_episodes/_meta.html.erb +++ b/app/views/podcast_episodes/_meta.html.erb @@ -1,4 +1,4 @@ -<% if @podcast %> +<% if @podcast && @podcast.persisted? %> <% title @podcast.title + " — The Practical Dev" %> <%= content_for :page_meta do %> diff --git a/app/views/podcast_episodes/_sidebar.html.erb b/app/views/podcast_episodes/_sidebar.html.erb index ccbe25a1f..d5460c37a 100644 --- a/app/views/podcast_episodes/_sidebar.html.erb +++ b/app/views/podcast_episodes/_sidebar.html.erb @@ -28,8 +28,8 @@
<% end %> -

If you know of a great dev podcast that should be added to this list, contact <%= SiteConfig.default_site_email %>

- +

<%= link_to "Suggest a Podcast", new_podcast_path, class: "suggest-podcast" %>

+ <% elsif @podcast %> <% end %> diff --git a/app/views/podcast_episodes/admin.html.erb b/app/views/podcast_episodes/admin.html.erb deleted file mode 100644 index 2d92cfd49..000000000 --- a/app/views/podcast_episodes/admin.html.erb +++ /dev/null @@ -1,52 +0,0 @@ -<% title @podcast.title + " — The Practical Dev" %> - -<%= content_for :page_meta do %> - - - - - - - - - - - - - - - - - - -<% end %> - - diff --git a/app/views/podcasts/_form.html.erb b/app/views/podcasts/_form.html.erb new file mode 100644 index 000000000..c96024ba5 --- /dev/null +++ b/app/views/podcasts/_form.html.erb @@ -0,0 +1,78 @@ +<%= form_with(model: podcast, local: true) do |form| %> +
+
+ < return to podcasts +

Suggest a Podcast

+
+ <% if podcast.errors.any? %> +
+

<%= pluralize(podcast.errors.count, "error") %> while creating a podcast:

+
    + <% podcast.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> +
+
+ <%= check_box_tag :i_am_owner %><%= label_tag :i_am_owner, "I am the owner of this podcast", class: "i_am_owner" %> +
+
+ <%= form.label :title %> + <%= form.text_field :title, placeholder: "" %> +
+
+ <%= form.label :description, "Description" %> + <%= form.text_area :description, placeholder: "" %> +
+
+ <%= form.label :website_url %> + <%= form.text_field :website_url %> +
+
+ <%= form.label :feed_url %> + <%= form.text_field :feed_url %> +
+
+ <%= form.label :twitter_username %> + <%= form.text_field :twitter_username %> +
+
+ <%= form.label :slug %> + <%= form.text_field :slug %> +
+
+ <%= form.label :main_color_hex %> + <%= form.text_field :main_color_hex, placeholder: "FF00FF" %> +
+
+ <%= form.label :image %> + <%= form.file_field :image %> +
+
+ <%= form.label :pattern_image %> + <%= form.file_field :pattern_image %> +
+
+ <%= form.label :android_url %> + <%= form.text_field :android_url %> +
+
+ <%= form.label :itunes_url %> + <%= form.text_field :itunes_url %> +
+
+ <%= form.label :overcast_url %> + <%= form.text_field :overcast_url %> +
+
+ <%= form.label :soundcloud_url %> + <%= form.text_field :soundcloud_url %> +
+
+ <%= form.submit "Suggest", class: "cta" %> +
+
+
+<% end %> diff --git a/app/views/podcasts/new.html.erb b/app/views/podcasts/new.html.erb new file mode 100644 index 000000000..8eed33f70 --- /dev/null +++ b/app/views/podcasts/new.html.erb @@ -0,0 +1,13 @@ +<%= content_for :page_meta do %> + <%= render "podcast_episodes/meta" %> +<% end %> +
+ <%= render "podcast_episodes/sidebar" %> +
+ <%= render "form", podcast: @podcast %> +
+ <%= render "podcast_episodes/sidebar_additional" %> +
+ +<%= render "stories/narrow_nav_menu" %> +<%= render "stories/stories_list_script" %> diff --git a/config/routes.rb b/config/routes.rb index 9c9a18040..a15c30e55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -201,6 +201,7 @@ Rails.application.routes.draw do resources :badges, only: [:index] resource :pro_membership, path: :pro, only: %i[show create update] resources :user_blocks, param: :blocked_id, only: %i[show create destroy] + resources :podcasts, only: %i[new create] resolve("ProMembership") { [:pro_membership] } # see https://guides.rubyonrails.org/routing.html#using-resolve get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" diff --git a/db/migrate/20191220120243_add_create_by_user_id_to_podcasts.rb b/db/migrate/20191220120243_add_create_by_user_id_to_podcasts.rb new file mode 100644 index 000000000..d7f169525 --- /dev/null +++ b/db/migrate/20191220120243_add_create_by_user_id_to_podcasts.rb @@ -0,0 +1,7 @@ +class AddCreateByUserIdToPodcasts < ActiveRecord::Migration[5.2] + def change + add_column :podcasts, :creator_id, :integer + add_index :podcasts, :creator_id + add_foreign_key :podcasts, :users, column: :creator_id, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b107808fa..0faae6c1b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,6 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema.define(version: 2019_12_27_114543) do - # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -750,6 +749,7 @@ ActiveRecord::Schema.define(version: 2019_12_27_114543) do create_table "podcasts", id: :serial, force: :cascade do |t| t.string "android_url" t.datetime "created_at", null: false + t.integer "creator_id" t.text "description" t.string "feed_url", null: false t.string "image", null: false @@ -767,6 +767,7 @@ ActiveRecord::Schema.define(version: 2019_12_27_114543) do t.boolean "unique_website_url?", default: true t.datetime "updated_at", null: false t.string "website_url" + t.index ["creator_id"], name: "index_podcasts_on_creator_id" t.index ["feed_url"], name: "index_podcasts_on_feed_url", unique: true t.index ["slug"], name: "index_podcasts_on_slug", unique: true end @@ -1202,6 +1203,7 @@ ActiveRecord::Schema.define(version: 2019_12_27_114543) do add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "page_views", "articles", on_delete: :cascade + add_foreign_key "podcasts", "users", column: "creator_id" add_foreign_key "sponsorships", "organizations" add_foreign_key "sponsorships", "users" add_foreign_key "tag_adjustments", "articles", on_delete: :cascade diff --git a/spec/fixtures/files/podcast.png b/spec/fixtures/files/podcast.png new file mode 100644 index 0000000000000000000000000000000000000000..bb55bced2063e8ed9146d0222a628758961d00d5 GIT binary patch literal 25033 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 N0Tnd>RSFhg{s+VjZWRCk literal 0 HcmV?d00001 diff --git a/spec/models/podcast_spec.rb b/spec/models/podcast_spec.rb index b05961fa1..bcfe28d78 100644 --- a/spec/models/podcast_spec.rb +++ b/spec/models/podcast_spec.rb @@ -9,6 +9,12 @@ RSpec.describe Podcast, type: :model do it { is_expected.to validate_presence_of(:main_color_hex) } it { is_expected.to validate_presence_of(:feed_url) } + it "has a creator" do + user = build(:user) + pod = create(:podcast, creator: user) + expect(pod.creator).to eq(user) + end + context "when callbacks are triggered after save" do it "triggers cache busting on save" do expect { build(:podcast).save }.to have_enqueued_job.on_queue("podcasts_bust_cache").once @@ -35,6 +41,20 @@ RSpec.describe Podcast, type: :model do expect(podcast2.errors[:feed_url]).to be_present end + it "validates feed_url format" do + podcast2 = build(:podcast, feed_url: "example.com") + + expect(podcast2).not_to be_valid + expect(podcast2.errors[:feed_url]).to be_present + end + + it "validates main_color_hex" do + podcast2 = build(:podcast, main_color_hex: "#FFFFFF") + + expect(podcast2).not_to be_valid + expect(podcast2.errors[:main_color_hex]).to be_present + end + it "doesn't allow to create a podcast with a reserved word slug" do enter_podcast = build(:podcast, slug: "enter") expect(enter_podcast).not_to be_valid diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 25df102d5..00dc4c381 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -35,6 +35,13 @@ RSpec.describe User, type: :model do it { is_expected.to have_one(:pro_membership).dependent(:destroy) } # rubocop:disable RSpec/NamedSubject + it "has created_podcasts" do + expect(subject).to have_many(:created_podcasts). + class_name("Podcast"). + with_foreign_key(:creator_id). + dependent(:nullify) + end + it do expect(subject).to have_many(:access_grants). class_name("Doorkeeper::AccessGrant"). diff --git a/spec/requests/podcasts/podcast_create_spec.rb b/spec/requests/podcasts/podcast_create_spec.rb new file mode 100644 index 000000000..f76ad915f --- /dev/null +++ b/spec/requests/podcasts/podcast_create_spec.rb @@ -0,0 +1,78 @@ +require "rails_helper" + +RSpec.describe "Podcast Create", type: :request do + let(:user) { create(:user) } + + context "when unauthorized user" do + it "redirects" do + get new_podcast_path + expect(response).to redirect_to(sign_up_path) + end + end + + context "when signed in" do + let(:valid_attributes) do + { + title: Faker::Hipster.words(number: 2).join(", "), + description: Faker::Hipster.paragraph(sentence_count: 1), + twitter_username: "hello-pod", + image: fixture_file_upload("files/podcast.png", "image/png"), + slug: "hello-pod", + main_color_hex: "ffffff", + website_url: Faker::Internet.url, + feed_url: Faker::Internet.url + } + end + + before do + sign_in user + end + + it "renders new" do + get new_podcast_path + expect(response).to be_successful + end + + it "creates a podcast with valid attributes" do + expect do + post podcasts_path, params: { podcast: valid_attributes } + end.to change(Podcast, :count).by(1) + end + + it "creates an unpublished podcast" do + post podcasts_path, params: { podcast: valid_attributes } + pod = Podcast.find_by(slug: valid_attributes[:slug]) + expect(pod.published).to be false + end + + it "creates a podcast with correct attributes" do + post podcasts_path, params: { podcast: valid_attributes } + pod = Podcast.find_by(slug: valid_attributes[:slug]) + expect(pod.title).to eq(valid_attributes[:title]) + end + + it "creates a podcast_admin role when created by an owner" do + post podcasts_path, params: { podcast: valid_attributes, i_am_owner: "1" } + pod = Podcast.find_by(title: valid_attributes[:title]) + expect(user.has_role?(:podcast_admin, pod)).to be true + end + + it "doesn't create a podcast_admin role when not created by an owner" do + post podcasts_path, params: { podcast: valid_attributes, i_am_owner: "" } + pod = Podcast.find_by(title: valid_attributes[:title]) + expect(user.has_role?(:podcast_admin, pod)).to be false + end + + it "sets the creator" do + post podcasts_path, params: { podcast: valid_attributes } + pod = Podcast.find_by(title: valid_attributes[:title]) + expect(pod.creator).to eq(user) + end + + it "doesn't create with invalid attributes" do + create(:podcast, slug: valid_attributes[:slug]) + post podcasts_path, params: { podcast: valid_attributes } + expect(response.body).to include("Suggest a Podcast") + end + end +end diff --git a/spec/requests/podcasts/podcast_episodes_index_spec.rb b/spec/requests/podcasts/podcast_episodes_index_spec.rb index 4afa5672a..4db7d5feb 100644 --- a/spec/requests/podcasts/podcast_episodes_index_spec.rb +++ b/spec/requests/podcasts/podcast_episodes_index_spec.rb @@ -4,7 +4,7 @@ RSpec.describe "PodcastEpisodesSpec", type: :request do describe "GET podcast episodes index" do it "renders page with proper sidebar" do get "/pod" - expect(response.body).to include("If you know of a great dev podcast") + expect(response.body).to include("Suggest a Podcast") end it "shows reachable podcasts" do