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 %> - -
-
- <% @podcast_episodes.each do |episode| %> - -
-
- <%= cl_image_tag(episode.image_url || @podcast.image_url, - type: "fetch", - crop: "fill", - width: 420, - height: 420, - quality: "auto", - flags: "progressive", - fetch_format: "auto", - sign_url: true, - alt: episode.title) %> -
-
- <%= episode.title %> -
-
-
- <% end %> -
- <%= paginate @podcast_episodes %> -
-
- -
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:

+ +
+ <% 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 000000000..bb55bced2 Binary files /dev/null and b/spec/fixtures/files/podcast.png differ 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