Home > database >  How to add star rating input field review form, Rails 7, Bootstrap (no jquery)?
How to add star rating input field review form, Rails 7, Bootstrap (no jquery)?

Time:10-20

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,");
}
  • Related