Add tag moderation feature (#201)

* Fix edge case for tag admin update

* Change single quotes to double quotes

* Add MVP of tag edit page

* Update rubocop version

* Use actual hex colors since vars broke

* Add enabling tag moderators via admin panel

* Remove unused boolean and variables

* Add a description for scholars

* Remove unintended method

* Update auth to work correctly

* Update boolean properly
This commit is contained in:
Andy Zhao 2018-04-11 18:16:49 -04:00 committed by Ben Halpern
parent 08c24609b1
commit 073e5444ac
18 changed files with 297 additions and 74 deletions

View file

@ -15,7 +15,7 @@ RSpec/DescribeClass:
AllCops:
Exclude:
- db/schema.rb
TargetRubyVersion: 2.3.4
TargetRubyVersion: 2.5.0
Naming/AccessorMethodName:
Description: Check the naming of accessor methods for get_/set_.

View file

@ -24,6 +24,7 @@
@import 'live';
@import 'delete-confirm';
@import 'preact/onboarding-modal';
@import 'tag-edit';
@import 'ltags/LiquidTags';

View file

@ -0,0 +1,39 @@
.tag-edit-form {
margin-bottom: 5em;
display: flex;
align-items: center;
justify-content: center;
}
.tag-edit-container {
margin-top: 5em;
max-width: 50%;
}
.tag-form-text-field {
height: 2em;
width: 20.5em;
text-align: left;
overflow-wrap: break-word;
font-size: 1em;
}
.tag-area-text {
height: 4em;
}
.tag-edit-submit {
margin-top: 5px;
background-color: #557de8;
color: white;
padding: 15px 20px;
text-align: center;
font-size: 16px;
}
.tag-edit-flash-success {
color: white;
background: #388019;
padding: 15px;
}
.tag-edit-flash-error {
color: white;
background: #ff0000;
padding: 15px 15px 15px 40px;
}

View file

@ -1,19 +1,46 @@
module Admin
class TagsController < Admin::ApplicationController
# To customize the behavior of this controller,
# simply overwrite any of the RESTful actions. For example:
#
# def index
# super
# @resources = Tag.all.paginate(10, params[:page])
# end
def update
@tag = Tag.find(params[:id])
if @tag.update(tag_params) && @tag.errors.messages.blank? && handle_moderators
flash[:notice] = "Tag successfully updated"
redirect_to "/admin/tags/#{@tag.id}"
else
render :new, locals: { page: Administrate::Page::Form.new(dashboard, @tag) }
end
end
# Define a custom finder by overriding the `find_resource` method:
# def find_resource(param)
# Tag.find_by!(slug: param)
# end
private
# See https://administrate-docs.herokuapp.com/customizing_controller_actions
# for more information
def convert_empty_string_to_nil
# Andy: nil plays nicely with our hex colors, whereas empty string doesn't
params[:tag][:text_color_hex] = nil if params[:tag][:text_color_hex] == ""
params[:tag][:bg_color_hex] = nil if params[:tag][:bg_color_hex] == ""
end
def tag_params
accessible = %i[
name
supported
alias_for
wiki_body_markdown
rules_markdown
short_summary
requires_approval
submission_template
submission_rules_headsup
pretty_name
bg_color_hex
text_color_hex
keywords_for_search
]
convert_empty_string_to_nil
params.require(:tag).permit(accessible)
end
def handle_moderators
user_ids = params[:tag][:tag_moderator_ids].split(",")
UserRoleService.new(nil).update_tag_moderators(user_ids.sort, @tag)
end
end
end

View file

@ -7,17 +7,12 @@ module Admin
flash[:notice] = "User successfully updated"
redirect_to "/admin/users/#{params[:id]}"
else
render_with_errors(user)
render :new, locals: { page: Administrate::Page::Form.new(dashboard, user) }
end
end
private
def render_with_errors(user)
flash.now[:notice] = user.errors.full_messages
render :new, locals: { page: Administrate::Page::Form.new(dashboard, user) }
end
def user_params
accessible = %i[name
email

View file

@ -5,4 +5,46 @@ class TagsController < ApplicationController
@tags_index = true
@tags = Tag.all.order("hotness_score DESC").first(100)
end
def edit
@tag = Tag.find_by!(name: params[:tag])
check_authorization
end
def update
@tag = Tag.find_by!(id: params[:id])
check_authorization
if @tag.errors.messages.blank? && @tag.update(tag_params)
flash[:success] = "Tag successfully updated! 👍 "
redirect_to "/t/#{@tag.name}/edit"
else
flash[:error] = @tag.errors.full_messages
render :edit
end
end
private
def check_authorization
raise unless current_user.has_role?(:super_admin) || current_user.has_role?(:tag_moderator, @tag)
end
def convert_empty_string_to_nil
# Andy: nil plays nicely with our hex colors, whereas empty string doesn't
params[:tag][:text_color_hex] = nil if params[:tag][:text_color_hex] == ""
params[:tag][:bg_color_hex] = nil if params[:tag][:bg_color_hex] == ""
end
def tag_params
accessible = %i[
wiki_body_markdown
rules_markdown
short_summary
pretty_name
bg_color_hex
text_color_hex
]
convert_empty_string_to_nil
params.require(:tag).permit(accessible)
end
end

View file

@ -11,6 +11,7 @@ class TagDashboard < Administrate::BaseDashboard
id: Field::Number,
name: Field::String,
supported: Field::Boolean,
tag_moderator_ids: TagModeratorsField,
wiki_body_markdown: Field::Text,
wiki_body_html: Field::Text,
rules_markdown: Field::Text,
@ -48,6 +49,7 @@ class TagDashboard < Administrate::BaseDashboard
:id,
:name,
:supported,
:tag_moderator_ids,
:alias_for,
:wiki_body_markdown,
:wiki_body_html,
@ -71,6 +73,7 @@ class TagDashboard < Administrate::BaseDashboard
FORM_ATTRIBUTES = [
:name,
:supported,
:tag_moderator_ids,
:alias_for,
:wiki_body_markdown,
:rules_markdown,

View file

@ -0,0 +1,7 @@
require "administrate/field/base"
class TagModeratorsField < Administrate::Field::Base
def to_s
data.join(",")
end
end

View file

@ -244,8 +244,7 @@ class Comment < ApplicationRecord
end
def wrap_timestamps_if_video_present!
return if commentable_type == 'PodcastEpisode'
return unless commentable.video.present?
return unless commentable_type != "PodcastEpisode" && commentable.video.present?
self.processed_html = processed_html.gsub(/(([0-9]:)?)(([0-5][0-9]|[0-9])?):[0-5][0-9]/) {|s| "<a href='#{commentable.path}?t=#{s}'>#{s}</a>"}
end

View file

@ -13,7 +13,7 @@ class Role < ApplicationRecord
in: %w(
super_admin
admin
moderator
tag_moderator
trusted
banned
warned

View file

@ -1,12 +1,14 @@
class Tag < ActsAsTaggableOn::Tag
acts_as_followable
resourcify
mount_uploader :profile_image, ProfileImageUploader
mount_uploader :social_image, ProfileImageUploader
validates :text_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true
validates :bg_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true
validates :text_color_hex,
format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_nil: true
validates :bg_color_hex,
format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_nil: true
validate :validate_alias
before_validation :evaluate_markdown
@ -18,6 +20,10 @@ class Tag < ActsAsTaggableOn::Tag
submission_template.gsub("PARAM_0", param_0)
end
def tag_moderator_ids
User.with_role(:tag_moderator, self).map(&:id).sort
end
private
def evaluate_markdown
@ -49,7 +55,7 @@ class Tag < ActsAsTaggableOn::Tag
end
def pound_it
text_color_hex&.prepend("#") unless text_color_hex&.starts_with?("#")
bg_color_hex&.prepend("#") unless bg_color_hex&.starts_with?("#")
text_color_hex&.prepend("#") unless text_color_hex&.starts_with?("#") || text_color_hex.blank?
bg_color_hex&.prepend("#") unless bg_color_hex&.starts_with?("#") || bg_color_hex.blank?
end
end

View file

@ -1,8 +1,6 @@
class UserRoleService
def initialize(user)
@user = user
@bannable = nil
@warnable = nil
end
def check_for_roles(params)
@ -15,6 +13,22 @@ class UserRoleService
new_roles?(params)
end
def update_tag_moderators(user_ids, tag)
users = user_ids.map { |id| User.find(id) }
# Andy: Don't have to worry about comparing old and new values.
tag.tag_moderator_ids.each do |id|
User.find(id).remove_role(:tag_moderator, tag)
puts "removed #{id}"
end
users.each do |user|
user.add_role(:tag_moderator, tag)
puts "added #{user.id}"
end
rescue ActiveRecord::RecordNotFound
tag.errors[:moderator_ids] << ": user #{id} was not found"
return false
end
private
def new_roles?(params)
@ -32,29 +46,22 @@ class UserRoleService
def bannable?(params)
if params[:banned] == "0" && !params[:reason_for_ban].blank?
@user.errors[:banned] << "was not checked but had the reason filled out"
@bannable = false
elsif params[:banned] == "1" && params[:reason_for_ban].blank?
@user.errors[:reason_for_ban] << "can't be blank if banned is checked"
@bannable = false
elsif params[:banned] == "1"
ban(params[:reason_for_ban])
@bannable = true
else
unban
@bannable = true
end
end
def warnable?(params)
if params[:warned] == "0" && !params[:reason_for_warning].blank?
@user.errors[:warned] << "was not checked but had the reason filled out"
@warnable = false
elsif params[:warned] == "1" && params[:reason_for_warning].blank?
@user.errors[:reason_for_warning] << "can't be blank if warned is checked"
@warnable = false
elsif params[:warned] == "1"
give_warning(params[:reason_for_warning])
@warnable = true
end
end
@ -80,10 +87,16 @@ class UserRoleService
@user.remove_role :banned
end
# Andy: Only give warning method b/c no need to remove warnings
def give_warning(content)
@user.add_role :warned
create_or_update_note("warned", content)
end
# Andy: no need to remove a warning from a user
def validate_user_ids(user_ids)
user_ids.each do |id|
User.find(id)
end
end
end

View file

@ -0,0 +1,7 @@
<div class="field-unit__label">
<%= f.label field.attribute %>
</div>
<div class="field-unit__field">
<%= f.text_field field.attribute, value: field.to_s, placeholder: "comma separated, space is optional. Example: 1,2,3" %>
<sub>comma separated, space is optional. Example: <code style="background: #D2D2D2; padding: 4px; border-radius: 5px; font-weight: bold;">1,2,3</code></sub>
</div>

View file

@ -0,0 +1 @@
<%= field.to_s %>

View file

@ -0,0 +1 @@
<%= field.to_s %>

View file

@ -1,5 +1,6 @@
<div class="field-unit__label">
<%= f.label field.attribute %>
<sub>Defaults to 1 year. Clear the date for no expiration.</sub>
</div>
<div class="field-unit__field" style="width: 2em;">
<%= f.check_box field.attribute %>

View file

@ -0,0 +1,79 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jscolor/2.0.4/jscolor.min.js"></script>
<style>
.widget header{
color: <%= HexComparer.new([@tag.bg_color_hex || "#0000000",@tag.text_color_hex || "#ffffff"]).brightness(0.8) %>
}
.slanty-accent{
background-color: <%= HexComparer.new([@tag.bg_color_hex || "#ffffff"]).accent %>
}
.tag-edit-tag {
text-align: center;
color: <%= @tag.text_color_hex %>;
}
</style>
<br>
<br>
<br>
<div class="user-profile-header tag-header" style="background-color:<%=@tag.bg_color_hex %>;color:<%=@tag.text_color_hex %>;">
<div class="slanty-accent"></div>
<div class="tag-or-query-header-container tag-edit-tag">
<h1> Editing: <a href="/t/<%= @tag.name %>" style="color: <%= @tag.text_color_hex %>;text-decoration: underline;"><%= @tag.name %></a> </h1>
<p><code style="background-color: #D2D2D2; color: black; padding: 5px; border-radius: 5px;"><%= @tag.name %></code> is the name of the tag used in Markdown.</p>
</div>
</div>
<%= form_tag("/tag/#{@tag.id}", method: :patch, class: "tag-edit-form") do %>
<div class="tag-edit-container">
<% if flash[:success] %>
<p class="tag-edit-flash-success">
<%= flash[:success] %>
</p>
<% elsif flash[:error] %>
<ul class="tag-edit-flash-error">
<% flash[:error].each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
<h4>
<a target="_blank" href="https://thepracticaldev.s3.amazonaws.com/i/47bnwguw2rv8npa85egm.png">
Click here to see an example of attributes.
</a>
</h4>
<div class="tag-form-field">
<%= label_tag :pretty_name %>
<br>
<%= text_field_tag "tag[pretty_name]", @tag.pretty_name, placeholder: "This is the name at the top of the show page.", class: "tag-form-text-field" %>
</div>
<div class="tag-form-field">
<%= label_tag :bg_color_hex %>
<br>
<%= text_field_tag "tag[bg_color_hex]", @tag.bg_color_hex, placeholder: "Click for color picker", class: "tag-form-text-field jscolor {hash:true, required:false}" %>
</div>
<div class="tag-form-field">
<%= label_tag :text_color_hex %>
<br>
<%= text_field_tag "tag[text_color_hex]", @tag.text_color_hex, placeholder: "Click for color picker", class: "tag-form-text-field jscolor {hash:true, required:false}" %>
</div>
<div class="tag-form-field">
<%= label_tag :short_summary %>
<br>
<%= text_area_tag "tag[short_summary]", @tag.short_summary, placeholder: "", class: "tag-form-text-field tag-area-text" %>
</div>
<div class="tag-form-field">
<%= label_tag :rules_markdown %>
<br>
<%= text_area_tag "tag[rules_markdown]", @tag.rules_markdown, placeholder: "", class: "tag-form-text-field tag-area-text" %>
</div>
<div class="tag-form-field">
<%= label_tag :wiki_body_markdown %>
<br>
<%= text_area_tag "tag[wiki_body_markdown]", @tag.wiki_body_markdown, placeholder: "", class: "tag-form-text-field tag-area-text" %>
</div>
<div class="tag-form-field">
<%= submit_tag "Save Changes", class: "tag-edit-submit" %>
</div>
</div>
<% end %>

View file

@ -1,11 +1,11 @@
Rails.application.routes.draw do
devise_for :users, controllers: {
omniauth_callbacks: 'omniauth_callbacks',
omniauth_callbacks: "omniauth_callbacks",
session: "sessions",
registrations: "registrations" }
devise_scope :user do
delete "/sign_out" => "devise/sessions#destroy"
get '/enter' => 'registrations#new', :as => :new_user_registration_path
get "/enter" => "registrations#new", :as => :new_user_registration_path
end
namespace :admin do
@ -36,7 +36,7 @@ Rails.application.routes.draw do
end
namespace :api, defaults: {format: 'json'} do
namespace :api, defaults: {format: "json"} do
scope module: :v0, constraints: ApiConstraints.new(version: 0, default: true) do
resources :articles, only: [:index, :show] do
collection do
@ -97,7 +97,7 @@ Rails.application.routes.draw do
### Subscription vanity url
post "membership-action" => "stripe_subscriptions#create"
get '/async_info/base_data', controller: 'async_info#base_data', defaults: { format: :json }
get "/async_info/base_data", controller: "async_info#base_data", defaults: { format: :json }
get "/hello-goodbye-to-the-go-go-go", to: redirect("ben/hello-goodbye-to-the-go-go-go")
get "/dhh-on-the-future-of-rails", to: redirect("ben/dhh-on-the-future-of-rails")
@ -174,7 +174,7 @@ Rails.application.routes.draw do
match "/delayed_job_admin" => DelayedJobWeb, :anchor => false, via: [:get, :post]
match '/users/:id/finish_signup' => 'users#finish_signup', via: [:get, :patch], :as => :finish_signup
match "/users/:id/finish_signup" => "users#finish_signup", via: [:get, :patch], :as => :finish_signup
get "/settings/(:tab)" => "users#edit"
get "/signout_confirm" => "users#signout_confirm"
@ -187,51 +187,53 @@ Rails.application.routes.draw do
# for testing rails mailers
if !Rails.env.production?
get '/rails/mailers' => "rails/mailers#index"
get '/rails/mailers/*path' => "rails/mailers#preview"
get "/rails/mailers" => "rails/mailers#index"
get "/rails/mailers/*path" => "rails/mailers#preview"
end
get "/new" => 'articles#new'
get "/new/:template" => 'articles#new'
get "/new" => "articles#new"
get "/new/:template" => "articles#new"
get "/pod" => "podcast_episodes#index"
get "/readinglist" => "reading_list_items#index"
get "/feed" => "articles#feed", :as => "feed", :defaults => { :format => 'rss' }
get "/feed/:username" => "articles#feed", :as => "user_feed", :defaults => { :format => 'rss' }
get "/rss" => "articles#feed", :defaults => { :format => 'rss' }
get "/feed" => "articles#feed", :as => "feed", :defaults => { :format => "rss" }
get "/feed/:username" => "articles#feed", :as => "user_feed", :defaults => { :format => "rss" }
get "/rss" => "articles#feed", :defaults => { :format => "rss" }
get "/amp/:slug" => 'articles#amp'
get "/amp/:username/:slug" => 'articles#amp', :constraints => { username: /@[\S]*/}
get "/amp/:slug" => "articles#amp"
get "/amp/:username/:slug" => "articles#amp", :constraints => { username: /@[\S]*/}
get "/tag/:tag" => 'stories#index'
get "/t/:tag" => 'stories#index'
get "/t/:tag/top/:timeframe" => 'stories#index'
get "/t/:tag/:timeframe" => 'stories#index', constraints: { timeframe: /latest/}
get "/tag/:tag" => "stories#index"
get "/t/:tag" => "stories#index"
get "/t/:tag/edit", to: "tags#edit"
patch "/tag/:id", to: "tags#update"
get "/t/:tag/top/:timeframe" => "stories#index"
get "/t/:tag/:timeframe" => "stories#index", constraints: { timeframe: /latest/}
get "/getting-started" => "onboarding#index"
get "/top/:timeframe" => 'stories#index'
get "/top/:timeframe" => "stories#index"
get "/:timeframe" => 'stories#index', constraints: { timeframe: /latest/}
get "/:timeframe" => "stories#index", constraints: { timeframe: /latest/}
get "/:username/:slug/comments/new/:parent_id_code" => 'comments#new'
get "/:username/:slug/comments/new" => 'comments#new'
get "/:username/:slug/comments" => 'comments#index'
get "/:username/:slug/comments/:id_code" => 'comments#index'
get "/:username/:slug/comments/:id_code/edit" => 'comments#edit'
get "/:username/:slug/comments/:id_code/delete_confirm" => 'comments#delete_confirm'
get "/:username/:slug/comments/new/:parent_id_code" => "comments#new"
get "/:username/:slug/comments/new" => "comments#new"
get "/:username/:slug/comments" => "comments#index"
get "/:username/:slug/comments/:id_code" => "comments#index"
get "/:username/:slug/comments/:id_code/edit" => "comments#edit"
get "/:username/:slug/comments/:id_code/delete_confirm" => "comments#delete_confirm"
get "/:username/comment/:id_code" => 'comments#index'
get "/:username/comment/:id_code/edit" => 'comments#edit'
get "/:username/comment/:id_code/delete_confirm" => 'comments#delete_confirm'
get "/:username/comment/:id_code" => "comments#index"
get "/:username/comment/:id_code/edit" => "comments#edit"
get "/:username/comment/:id_code/delete_confirm" => "comments#delete_confirm"
get "/:username/:slug/:view" => 'stories#show', constraints: { view: /moderate/}
get "/:username/:slug/edit" => 'articles#edit'
get "/:username/:slug/delete_confirm" => 'articles#delete_confirm'
get "/:username/:view" => 'stories#index', constraints: { view: /comments|moderate|admin/}
get "/:username/:slug" => 'stories#show'
get "/:username" => 'stories#index'
get "/:username/:slug/:view" => "stories#show", constraints: { view: /moderate/}
get "/:username/:slug/edit" => "articles#edit"
get "/:username/:slug/delete_confirm" => "articles#delete_confirm"
get "/:username/:view" => "stories#index", constraints: { view: /comments|moderate|admin/}
get "/:username/:slug" => "stories#show"
get "/:username" => "stories#index"
root 'stories#index'
root "stories#index"
end