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 <bendhalpern@gmail.com>
This commit is contained in:
Anna Buianova 2020-01-09 00:14:25 +03:00 committed by Ben Halpern
parent 482a77a375
commit e09e46ec99
21 changed files with 359 additions and 58 deletions

View file

@ -1227,6 +1227,11 @@
);
}
}
.suggest-podcast {
color: var(--theme-secondary-color, #666666);
text-decoration: underline;
padding: 0 5px;
}
}
hr {
opacity: 0.2;

View file

@ -11,6 +11,7 @@
@import 'settings';
@import 'signin';
@import 'podcast-episodes-show';
@import 'podcast-form';
@import 'more-articles';
@import 'user-profile-header';
@import 'comments';

View file

@ -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;
}
}
}

View file

@ -64,6 +64,8 @@ body {
&.notifications-index,
&.stories-search,
&.podcast_episodes-index,
&.podcasts-new,
&.podcasts-create,
&.reading_list_items-index,
&.history-index,
.tags-index,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -22,6 +22,10 @@
<p>There are no admins for this podcast.</p>
<% end %>
<% if @podcast.creator.present? %>
<p>Created by <%= link_to "@#{@podcast.creator.username}", "/#{@podcast.creator.username}" %></p>
<% end %>
<%= form_for @podcast, url: add_admin_internal_podcast_path(@podcast.id), html: { method: :post } do |f| %>
<div class="form-group">
<%= f.label "Add Admin (by user_id)", for: "podcast_user_id" %>

View file

@ -1,4 +1,4 @@
<% if @podcast %>
<% if @podcast && @podcast.persisted? %>
<% title @podcast.title + " — The Practical Dev" %>
<%= content_for :page_meta do %>

View file

@ -28,8 +28,8 @@
</div>
</a>
<% end %>
<p>If you know of a great dev podcast that should be added to this list, contact <a href="mailto:<%= SiteConfig.default_site_email %>"><%= SiteConfig.default_site_email %></a></p>
</div>
<p><%= link_to "Suggest a Podcast", new_podcast_path, class: "suggest-podcast" %></p>
</div>
</div>
<% elsif @podcast %>
<% end %>

View file

@ -1,52 +0,0 @@
<% title @podcast.title + " — The Practical Dev" %>
<%= content_for :page_meta do %>
<link rel="canonical" href="https://dev.to/<%= @podcast.slug %>" />
<meta name="description" content="Software Engineering Daily is a programming podcast that airs five days a week.">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<meta property="og:type" content="article" />
<meta property="og:url" content="https://dev.to/<%= @podcast.slug %>" />
<meta property="og:title" content="Software Engineering Daily — The Practical Dev" />
<meta property="og:image" content="https://res.cloudinary.com/practicaldev/image/fetch/c_scale,h_180,w_180,r_max/u_devuserbg_npatyr,y_60/<%= cloudinary(@podcast.image_url, 180) %>">
<meta property="og:description" content="Software Engineering Daily is a programming podcast that airs five days a week" />
<meta property="og:site_name" content="The Practical Dev" />
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@ThePracticalDev">
<meta name="twitter:creator" content="@software_daily">
<meta name="twitter:title" content="Software Engineering Daily — The Practical Dev">
<meta name="twitter:description" content="Software Engineering Daily is a programming podcast that airs five days a week." />
<meta name="twitter:image:src" content="https://res.cloudinary.com/practicaldev/image/fetch/c_scale,h_180,w_180,r_max/u_devuserbg_npatyr,y_60/<%= cloudinary(@podcast.image_url, 180) %>">
<% end %>
<div class="podcast-index-container">
<div class="episodes-container">
<% @podcast_episodes.each do |episode| %>
<a href="<%= episode.path %>/edit">
<div class="individual-episode">
<div class="pic">
<%= 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) %>
</div>
<div class="title">
<%= episode.title %>
</div>
</div>
</a>
<% end %>
<div class="pagination-wrapper">
<%= paginate @podcast_episodes %>
</div>
</div>
</div>

View file

@ -0,0 +1,78 @@
<%= form_with(model: podcast, local: true) do |form| %>
<div>
<header>
<a href="/pod" class="podcast-back-button">&lt; return to podcasts</a>
<h2>Suggest a Podcast</h2>
</header>
<% if podcast.errors.any? %>
<div class="podcast-errors">
<h2><%= pluralize(podcast.errors.count, "error") %> while creating a podcast:</h2>
<ul>
<% podcast.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div id="podcast-form-data">
<div class="field">
<%= check_box_tag :i_am_owner %><%= label_tag :i_am_owner, "I am the owner of this podcast", class: "i_am_owner" %>
</div>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title, placeholder: "" %>
</div>
<div class="field">
<%= form.label :description, "Description" %>
<%= form.text_area :description, placeholder: "" %>
</div>
<div class="field">
<%= form.label :website_url %>
<%= form.text_field :website_url %>
</div>
<div class="field">
<%= form.label :feed_url %>
<%= form.text_field :feed_url %>
</div>
<div class="field">
<%= form.label :twitter_username %>
<%= form.text_field :twitter_username %>
</div>
<div class="field">
<%= form.label :slug %>
<%= form.text_field :slug %>
</div>
<div class="field">
<%= form.label :main_color_hex %>
<%= form.text_field :main_color_hex, placeholder: "FF00FF" %>
</div>
<div class="field">
<%= form.label :image %>
<%= form.file_field :image %>
</div>
<div class="field">
<%= form.label :pattern_image %>
<%= form.file_field :pattern_image %>
</div>
<div class="field">
<%= form.label :android_url %>
<%= form.text_field :android_url %>
</div>
<div class="field">
<%= form.label :itunes_url %>
<%= form.text_field :itunes_url %>
</div>
<div class="field">
<%= form.label :overcast_url %>
<%= form.text_field :overcast_url %>
</div>
<div class="field">
<%= form.label :soundcloud_url %>
<%= form.text_field :soundcloud_url %>
</div>
<div class="field">
<%= form.submit "Suggest", class: "cta" %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1,13 @@
<%= content_for :page_meta do %>
<%= render "podcast_episodes/meta" %>
<% end %>
<div class="home">
<%= render "podcast_episodes/sidebar" %>
<div class="podcasts-form" id="podcasts-form">
<%= render "form", podcast: @podcast %>
</div>
<%= render "podcast_episodes/sidebar_additional" %>
</div>
<%= render "stories/narrow_nav_menu" %>
<%= render "stories/stories_list_script" %>

View file

@ -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"

View file

@ -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

View file

@ -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

BIN
spec/fixtures/files/podcast.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -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

View file

@ -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").

View file

@ -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

View file

@ -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