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| %>
+
+
+ <% 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 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