Im working on adding voting to my app (like in Stackoveflow). I have two models Questions and Answers so i want to be able to vote for both of them. I see two ways to manage voting for different type of models:
Add concern for models, controllers and routes.
Add votes_controller that can handle voting for any model that has votable.
I`d like to use second way to solve my problem. But to use controller I will should pass two parameters to controller, like: votable_type: model-name, votable-id: object.id, and my route will look like: vote_up_vote_path, vote_down_vote_path.
Is there a way to use routes like: vote_up_path(answer); vote_down_path(question)?
And by passing object "vote_up_path (answer)" i want to be able to get it in controller
P.S. I`m not able to use gems. Gems provide logic for models, I'm already have this logic.
I found the solution. So at first we need to generate Votes controller.
$rg controller Votes
Than we add routes:
resource :vote, only: [:vote_up, :vote_down, :unvote] do
patch :vote_up, on: :member
patch :vote_down, on: :member
patch :unvote, on: :member
end
And add in votes_helper.rb:
module VotesHelper
def vote_up_path(votable)
{controller: "votes", action: "vote_up",
votable_id: votable.id, votable_type: votable.class}
end
def vote_down_path(votable)
{controller: "votes", action: "vote_down",
votable_id: votable.id, votable_type: votable.class}
end
def unvote_path(votable)
{controller: "votes", action: "unvote",
votable_id: votable.id, votable_type: votable.class}
end
end
Than we should add tests and complete our methods. In controller we can use this method to find our votable:
private
def set_votable
klass = params[:votable_type].to_s.capitalize.constantize
#votable = klass.find(params[:votable_id])
end
Highly recommend this gem to handle upvote/downvote:
Acts as votable
There are two ways to do this; the first is to use superclassed controllers, the second is to use an individual controller with a polymorphic model.
I'll detail both:
Superclassing
You can set a method in a "super" controller, which will be inherited by the answers and questions controllers respectively. This is kind of lame because it means you're sending requests to the answers or questions controllers (which is against the DRY (Don't Repeat Yourself) principle):
#config/routes.rb
resources :answers, :questions do
match :vote, via: [:post, :delete], on: :member
end
#app/controllers/votes_controller.rb
class VotesController < ApplicationController
def vote
if request.post?
# Upvote
elsif request.delete?
# Downvote
end
end
end
#app/controllers/answers_controller.rb
class AnswersController < VotesController
# Your controller as normal
end
... Even better ...
#app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def vote
...
end
end
#app/controllers/answers_controller.rb
class AnswersController < ApplicationController
....
end
This will allow you send the following:
<%= link_to "Upvote", answers_vote_path(#answer), method: :post %>
<%= link_to "Downvote", questions_vote_path(#question), method: :delete %>
-
The important factor here is that you need to ensure your vote logic is correct in the backend, which you'll then be able to use the vote method in the VotesController to get it working.
Polymorphic Model
Perhaps the simplest way will be to use a polymorphic model:
#app/models/vote.rb
class Vote < ActiveRecord::Base
belongs_to :voteable, polymorphic: true
end
#app/models/answer.rb
class Answer < ActiveRecord::Base
has_many :votes, as: :voteable
end
#app/models/vote.rb
class Vote < ActiveRecord::Base
has_many :votes, as: :voteable
end
This will allow you to do the following:
#config/routes.rb
resources vote, only: [] do
match ":voteable_type/:voteable_id", action: :vote, via: [:post,:delete], on: :collection #-> url.com/vote/answer/3
end
#app/controllers/votes_controller.rb
class VotesController < ApplicationController
def vote
if request.post?
#vote = Vote.create vote_params
elsif request.delete?
#vote = Vote.destroy vote_params
end
end
private
def vote_params
params.permit(:voteable_id, :voteable_type)
end
end
This would allow you to use the following:
<%= link_to "Vote", votes_path(:answer, #answer.id) %>
Related
I have a Ruby on Rails application that can generate "roles" for actors in movies; the idea is that if a user looks at a movie detail page, they can click "add role", and the same if they look at an actor detail page.
Once the role is generated, I would like to redirect back to where they came from - the movie detail page or the actor detail page... so in the controller's "create" and "update" method, the redirect_to should either be movie_path(id) or actor_path(id). How do I keep the "origin" persistent, i. e. how do I remember whether the user came from movie detail or from actor detail (and the id, respectively)?
I would setup separate nested routes and just use inheritance, mixins and partials to avoid duplication:
resources :movies do
resources :roles, module: :movies, only: :create
end
resources :actors do
resources :roles, module: :actors, only: :create
end
class RolesController < ApplicationController
before_action :set_parent
def create
#role = #parent.roles.create(role_params)
if #role.save
redirect_to #parent
else
render :new
end
end
private
# guesses the name based on the module nesting
# assumes that you are using Rails 6+
# see https://stackoverflow.com/questions/133357/how-do-you-find-the-namespace-module-name-programmatically-in-ruby-on-rails
def parent_class
module_parent.name.singularize.constantize
end
def set_parent
parent_class.find(param_key)
end
def param_key
parent_class.model_name.param_key + "_id"
end
def role_params
params.require(:role)
.permit(:foo, :bar, :baz)
end
end
module Movies
class RolesController < ::RolesController
end
end
module Actors
class RolesController < ::RolesController
end
end
# roles/_form.html.erb
<%= form_with(model: [parent, role]) do |form| %>
# ...
<% end %>
I have this setup:
routes.rb:
resources :orders, only: [] do
# many other member actions
# ...
namespace :confirm, module: :orders do
controller :confirm do
get :cash
end
end
end
orders/confirm_controller.rb:
class Orders::ConfirmController < ApplicationController
load_and_authorize_resource class: Order,
id_param: :order_id,
instance_name: :order
def cash
end
end
ability.rb:
can :cash, Order do |order|
order.buyer == user
end
The idea is: since OrdersController already overloaded with almost 20 actions, I want move new actions to nested controller. The code above is working but I feel that this part:
load_and_authorize_resource class: Order,
id_param: :order_id,
instance_name: :order
can be done easier. And ability that I have is for parent controller and not namespaced to nested controller, so if someone define cash method in OrdersController same ability will be used, what is not good. So is there any better way?
I have my app setup where users can write reviews for a movie. What I'd like to do is limit the user to create only one review per movie. I've managed to accomplish this in my reviews controller as so:
class ReviewsController < ApplicationController
before_action :has_reviewed, only [:new]
....
def has_reviewed?
if Review.where(user_id: current_user.id, movie_id: #movie.id).any?
redirect_to movie_reviews_path
flash[:notice] = "You've already written a review for this movie."
end
end
end
Where I'm now having trouble is translating this same logic into my index view template with the helper methods of Devise and CanCanCan at my disposal.
<% if user_signed_in? && ... %> # current_user has already created a review for this movie
<%= link_to "Edit Review", edit_movie_review_path(#movie, review) %>
<% else %>
<%= link_to "Write a Review", new_movie_review_path %>
<% end %>
Also: Is there any way to improve the lookup in my has_reviewed? method? I feel like there's a better way to write it but can't determine the most appropriate fix.
Why not use a validation:
#app/models/review.rb
class Review < ActiveRecord::Base
validates :movie_id, uniqueness: { scope: :user_id, message: "You've reviewed this movie!" }
end
This is considering your review model belongs_to :movie
You could also use an ActiveRecord callback:
#app/models/review.rb
class Review < ActiveRecord::Base
before_create :has_review?
belongs_to :user, inverse_of: :reviews
belongs_to :movie
def has_review?
return if Review.exists?(user: user, movie_id: movie_id)
end
end
#app/models/user.rb
class User < ActiveRecord::Base
has_many :reviews, inverse_of: :user
end
Is there any way to improve the lookup in my has_reviewed? method?
def has_reviewed?
redirect_to album_reviews_path, notice: "You've already written a review for this album." if current_user.reviews.exists?(movie: #movie)
end
Why not make a has_reviewed? method on your User class?
e.g.
def has_reviewed?(reviewable)
# query in here
end
Then you should be able use that just fine in your controller and your views.
You will want to do this for both new and create. Otherwise a savvy user would be able to run a post that would get past your new action.
I would put the link_to in either a helper or a presenter object. It would generally look like this.
def create_or_edit_review_path(movie, current_user)
return '' if current_user.blank?
if current_user.review.present?
#Generate review edit link
else
#generate new link
end
end
After that in all of your views it would just be
<%= create_or_edit_review_path(#movie, current_user) %>
Then in your controller for both new and create you could do either a before action or just redirect on each.
before_action :enforce_single_review, only: [:create, :new]
def enforce_single_review
if current_user.review.present?
redirect_to review_path(current_user.review)
end
end
Here's what I came up with:
I created an instance method to retrieve a user's movie review using the find_by method on the Review model:
class User < ActiveRecord::Base
....
def movie_review(album)
Review.find_by(user_id: self, album_id: album)
end
end
This method also comes in handy when setting up my callback:
class ReviewsController < ApplicationController
before_action :limit_review, only: [:new, :create]
....
private
def limit_review
user_review = current_user.movie_review(#movie)
if user_review.present?
redirect_to edit_movie_review_path(#movie, user_review)
end
end
end
Created a helper method for showing the appropriate link to edit or create a review. Big thanks to Austio and his suggestion:
module ReviewsHelper
def create_or_edit_review_path(movie)
user_review = current_user.movie_review(movie) if user_signed_in?
if user_signed_in? && user_review.present?
link_to "Edit review", edit_movie_review_path(movie, user_review)
else
link_to "Write a review", new_movie_review_path
end
end
end
And at last this is how I call the helper in my view template(s):
....
<%= create_or_edit_review_path(#album) %>
I have the following association setup
class Donation < ActiveRecord::Base
belongs_to :campaign
end
class Campaign < ActiveRecord::Base
has_many :donations
end
in my donation controller i have campaign_id as a strong parameter. Now when creating a donation i would like to have the campaign_id available to save in that form, but better off in the controller somewhere im guessing as less open to being edited. To get from the campaign to the donation form i click a button
<%= link_to 'Donate', new_donation_path, class: 'big-button green' %>
as we are already using
def show
#campaign = Campaign.find(params[:id])
end
How can i get that id into my donations form?
Thanks
It sounds like you're looking for Nested Resources.
Basically, your routes.rb might look something like:
resources :campaigns do
resources :donations
end
Your controller might look something like:
class DonationsController < ApplicationController
before_action :find_campaign
def new
#donation = #campaign.donations.build
end
def create
#donation = #campaign.donations.create(params[:donation])
# ...
end
protected
def find_campaign
#campaign ||= Campaign.find(params[:campaign_id])
end
end
And your example link, something like...
<%= link_to 'Donate', new_campaign_donation_path(#campaign), class: 'big-button green' %>
Given the routes:
Example::Application.routes.draw do
concern :commentable do
resources :comments
end
resources :articles, concerns: :commentable
resources :forums do
resources :forum_topics, concerns: :commentable
end
end
And the model:
class Comment < ActiveRecord::Base
belongs_to :commentable, polymorphic: true
end
When I edit or add a comment, I need to go back to the "commentable" object. I have the following issues, though:
1) The redirect_to in the comments_controller.rb would be different depending on the parent object
2) The references on the views would differ as well
= simple_form_for comment do |form|
Is there a practical way to share views and controllers for this comment resource?
In Rails 4 you can pass options to concerns. So if you do this:
# routes.rb
concern :commentable do |options|
resources :comments, options
end
resources :articles do
concerns :commentable, commentable_type: 'Article'
end
Then when you rake routes, you will see you get a route like
POST /articles/:id/comments, {commentable_type: 'Article'}
That will override anything the request tries to set to keep it secure. Then in your CommentsController:
# comments_controller.rb
class CommentsController < ApplicationController
before_filter :set_commentable, only: [:index, :create]
def create
#comment = Comment.create!(commentable: #commentable)
respond_with #comment
end
private
def set_commentable
commentable_id = params["#{params[:commentable_type].underscore}_id"]
#commentable = params[:commentable_type].constantize.find(commentable_id)
end
end
One way to test such a controller with rspec is:
require 'rails_helper'
describe CommentsController do
let(:article) { create(:article) }
[:article].each do |commentable|
it "creates comments for #{commentable.to_s.pluralize} " do
obj = send(commentable)
options = {}
options["#{commentable.to_s}_id"] = obj.id
options["commentable_type".to_sym] = commentable.to_s.camelize
options[:comment] = attributes_for(:comment)
post :create, options
expect(obj.comments).to eq [Comment.all.last]
end
end
end
You can find the parent in a before filter like this:
comments_controller.rb
before_filter: find_parent
def find_parent
params.each do |name, value|
if name =~ /(.+)_id$/
#parent = $1.classify.constantize.find(value)
end
end
end
Now you can redirect or do whatever you please depending on the parent type.
For example in a view:
= simple_form_for [#parent, comment] do |form|
Or in a controller
comments_controller.rb
redirect_to #parent # redirect to the show page of the commentable.