I am building a Rails 7 app using bootstrap (without jquery), in which users can leave a review for various parks. One of the review fields is a 5-star rating.
Currently I have the form input as a text_field, but I would like it to be 5 stars that the user can click on to select the rating, then submit together with the review form.
I am already displaying the park ratings after they've been submitted, but I am stuck on the input display.
I thought maybe I can somehow change the UI for a range_field or collection_radio_buttons, but I can't figure out how to make it work...
Park model
class Park < ApplicationRecord
has_many :visits, dependent: :destroy
has_many :visited_users, through: :visits, source: :user
has_many :favorites, dependent: :destroy
has_many :favorited_users, through: :favorites, source: :user
has_many :reviews, as: :reviewable
include Translatable
translates :name, :website_url
validates :name_en, presence: true
validates :name_he, presence: true
validates :region, presence: true
validates :latitude, presence: true
validates :longitude, presence: true
enum :region, { north: 0, center: 1, south: 2, west_bank: 3 }
enum :park_system, { kkl_jnf: 0, inpa: 1 },
default: :inpa
def default_image
Review&.where(reviewable: self)&.left_joins(:images_attachments)&.where&.not(active_storage_attachments: { id: nil })&.first&.images&.first
end
def favorited_by?(user)
return if user.nil?
favorited_users.include?(user)
end
def visited_by?(user)
return if user.nil?
visited_users.include?(user)
end
end
Review model
class Review < ApplicationRecord
has_many_attached :images, dependent: :destroy
# validates :title, presence: true
validates :body, presence: true
validates :rating, presence: true, numericality:
{ greater_than_or_equal_to: 1, less_than_or_equal_to: 5,
only_integer: true }
belongs_to :reviewable, polymorphic: true, counter_cache: true
belongs_to :user
after_commit :update_reviewable_rating, on: [:create, :update, :destroy]
def update_reviewable_rating
reviewable.update! average_rating: reviewable.reviews.average(:rating)
# avg (rating - avg) / count
end
end
Reviews controller
# frozen_string_literal: true
class ReviewsController < ApplicationController
before_action :authenticate_user!
before_action :find_park
before_action :find_review, only: %i[ edit update destroy ]
def new
@review = Review.new(reviewable: @park)
end
def create
@review = @park.reviews.new(review_params)
@review.reviewable_id = @park.id
@review.user_id = current_user.id
respond_to do |format|
if @review.save
format.html { redirect_to params[:previous_request], notice: "Your review for #{@park.name} was successfully added." }
format.json { render :show, status: :created, location: @park }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @review.errors, status: :unprocessable_entity }
end
end
end
def edit
end
def update
respond_to do |format|
if @review.update(review_params)
format.html { redirect_to params[:previous_request], notice: "Your review for #{@park.name} was successfully updated." }
format.json { render :show, status: :ok, location: @park }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @review.errors, status: :unprocessable_entity }
end
end
end
def destroy
@review.destroy
respond_to do |format|
format.html { redirect_to params[:previous_request], notice: "Your review for #{@park.name} was successfully deleted." }
format.json { head :no_content }
end
end
private
def review_params
params.require(:review).permit(:rating, :body, images: [])
end
def find_park
@park = Park.find(params[:park_id])
end
def find_review
@review = Review.find(params[:id])
end
end
Parks show view, reviews partial
<% @park.reviews.includes(:user).order("updated_at desc").each do |review| %>
<div >
<div >
<div >
<h5 >
<%= gravatar_for review.user, size: 50 %>
<div >
<%= link_to review.user.name, review.user, class: "link-dark" %>
</div>
</h5>
<h6 >
<%
review_star_classes = ["#DEDEDE", "#DEDEDE", "#DEDEDE", "#DEDEDE", "#DEDEDE"]
review.rating.times do |i|
review_star_classes[i] = "#fbbc04"
end
%>
<% review_star_classes.each do |star_class| %>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="<%= star_class %>"
width="16"
height="16"
>
<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z"
clip-rule="evenodd" />
</svg>
<% end %>
</h6>
<div >
<div>
<%= review.updated_at.to_fs(:long) %>
</div>
<div >
<%= simple_format(review.body) %>
</div>
<% if current_user && current_user === review.user %>
<div>
<%= link_to "Edit", edit_park_review_path(review.reviewable, review) %>
<%= button_to "Delete", park_review_path(review.reviewable, review),
class: "btn btn-link p-0 m-0 d-inline align-baseline text-decoration-none",
form: {data: { turbo_confirm: "Are you sure?"} },
method: :delete %>
</div>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
New review form
<%= form_with model: @review, url: park_reviews_path, local:true do |f| %>
<%= hidden_field_tag :previous_request, request.referer %>
<div >
<%= f.label :rating, "Rate your experience", style: "display: block" %>
<%= f.text_field :rating, class: "form-control",
placeholder: "From 1 to 5 stars",
autofocus: true %>
</div>
<div >
<%= f.text_area :body, class: "form-control",
placeholder: "Tell people about your experience" %>
</div>
<div >
<%= f.file_field :images, multiple: true %>
</div>
<div >
<%= f.submit "Submit your review", class: "btn btn-primary" %>
</div>
<% end %>
CodePudding user response:
I saw an even easier solution than using SVG, it is that one : https://codepen.io/GeoffreyCrofte/pen/ALOggg
They use the html char ★ or ☆ instead of an SVG. All happen in the below bit of code : (I have tweaked it a bit as it was intended to be used as a link, not a form input)
<div ><!--
--><input name="stars" id="e5" type="radio" value="5"><label for="e5">★</label><!--
--><input name="stars" id="e4" type="radio" value="4"><label for="e4">★</label><!--
--><input name="stars" id="e3" type="radio" value="3"><label for="e3">★</label><!--
--><input name="stars" id="e2" type="radio" value="2"><label for="e2">★</label><!--
--><input name="stars" id="e1" type="radio" value="1"><label for="e1">★</label>
</div>
Though you have to tweak it further so it blends into your form. Basically a form param name is determinded by the name of the input field. First the model name, and then in square brackets the name of the field :
<div ><!--
--><input name="review[rating]" id="e5" type="radio" value="5"><label for="e5">★</label><!--
--><input name="review[rating]" id="e4" type="radio" value="4"><label for="e4">★</label><!--
--><input name="review[rating]" id="e3" type="radio" value="3"><label for="e3">★</label><!--
--><input name="review[rating]" id="e2" type="radio" value="2"><label for="e2">★</label><!--
--><input name="review[rating]" id="e1" type="radio" value="1"><label for="e1">★</label>
</div>
You can keep the same CSS as in the CodePen, it should work.
CodePudding user response:
I'm not sure this was the best solution but I ended up with the following code, if it helps someone in the future. Definitely not perfect (for example, I had to make the label text white, since I couldn't figure out a way to display an empty label).
New review form
<%= form_with model: @review, url: park_reviews_path, local:true do |f| %>
<%= hidden_field_tag :previous_request, request.referer %>
<div >
<%= f.label :rating, "What's your overall rating?" %>
<div >
<%= f.collection_radio_buttons(:rating, [[5],[4],[3],[2],[1]], :first, :last) do |star| %>
<%= star.radio_button %>
<%= star.label(class: "text-white") %>
<% end %>
</div>
</div>
<div >
<%= f.label :body, "Leave a review" %>
<%= f.text_area :body, class: "form-control",
placeholder: "Tell people about your experience" %>
</div>
<div >
<%= f.label :images, "Add some photos" %>
<%= f.file_field :images, multiple: true %>
</div>
<div >
<%= f.submit "Submit your review", class: "btn btn-primary" %>
</div>
<% end %>
CSS
/*
* star rating
*/
.rating {
display: flex;
width: 100%;
justify-content: left;
overflow: hidden;
flex-direction: row-reverse;
// height: 150px;
position: relative;
}
.rating-0 {
filter: grayscale(100%);
}
.rating > input {
display: none;
}
.rating > label {
cursor: pointer;
width: 40px;
height: 40px;
margin-top: auto;
background-image: url("data:image/svg xml;charset=UTF-8,");
background-repeat: no-repeat;
background-position: center;
background-size: 76%;
transition: .3s;
}
.rating > input:checked ~ label,
.rating > input:checked ~ label ~ label {
background-image: url("data:image/svg xml;charset=UTF-8,");
}
.rating > input:not(:checked) ~ label:hover,
.rating > input:not(:checked) ~ label:hover ~ label {
background-image: url("data:image/svg xml;charset=UTF-8,");
}