diff --git a/app/assets/images/pencil-square.svg b/app/assets/images/pencil-square.svg new file mode 100644 index 0000000..edce8e6 --- /dev/null +++ b/app/assets/images/pencil-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9dcb1db..7ead4ff 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,8 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + before_action :configure_permitted_parameters, if: :devise_controller? unless Rails.env.production? @@ -20,4 +22,8 @@ def n_plus_one_detection def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: [ :nickname ]) end + + def record_not_found + render file: Rails.root.join("public", "404.html"), status: :not_found, layout: false + end end diff --git a/app/controllers/books/reviews_controller.rb b/app/controllers/books/reviews_controller.rb new file mode 100644 index 0000000..e5ea73c --- /dev/null +++ b/app/controllers/books/reviews_controller.rb @@ -0,0 +1,55 @@ +class Books::ReviewsController < ApplicationController + include FindBook + + before_action :authenticate_user! + before_action :find_review, only: [ :edit, :update ] + before_action :authorize_user, only: [ :edit, :update ] + + def new + @review = @book.reviews.new + end + + def create + @review = @book.reviews.new(review_params) + @review.user = current_user + + if @review.save + respond_to do |format| + format.html { redirect_to book_path(@book) } + format.turbo_stream + end + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @review.update(review_params) + respond_to do |format| + format.html { redirect_to(book_path(@book)) } + format.turbo_stream + end + else + render :edit, status: :unprocessable_entity + end + end + + private + + def review_params + params.require(:review).permit(:title, :description, :rating) + end + + def find_review + @review = @book.reviews.find(params[:id]) + end + + def authorize_user + unless @review.user_id == current_user.id + redirect_to book_path(@book) + end + end +end diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb index b9aebf1..d5d2bc1 100644 --- a/app/controllers/books_controller.rb +++ b/app/controllers/books_controller.rb @@ -1,5 +1,6 @@ class BooksController < ApplicationController include Pagy::Backend + include FindBook before_action :find_book, only: [ :show ] @@ -10,6 +11,7 @@ def index @books = Book.all end + @books = @books.with_attached_image @pagy, @books = pagy(@books) end @@ -17,12 +19,4 @@ def show @reviews = @book.reviews.eager_load(:user) @pagy, @reviews = pagy(@reviews) end - - private - - def find_book - @book = Book.find(params[:id]) - rescue ActiveRecord::RecordNotFound - render file: Rails.root.join("public", "404.html"), status: :not_found, layout: false - end end diff --git a/app/controllers/concerns/find_book.rb b/app/controllers/concerns/find_book.rb new file mode 100644 index 0000000..06fbcc4 --- /dev/null +++ b/app/controllers/concerns/find_book.rb @@ -0,0 +1,13 @@ +module FindBook + extend ActiveSupport::Concern + + included do + before_action :find_book + end + + private + + def find_book + @book = Book.find(params[:book_id] || params[:id]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 97ce8e2..88c2a2c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -30,7 +30,7 @@ def modal_form_for(record, options = {}, &block) options[:html] ||= {} options[:html][:class] = [ "min-w-[350px] space-y-6", options[:html][:class] ] options[:data] ||= {} - options[:data][:action] = "turbo:submit-end->modal#reloadOnSuccess" + options[:data][:action] = "turbo:submit-end->modal#closeOnSuccess" unless options[:data].key? :action form_for(record, options, &block) end end diff --git a/app/helpers/books/reviews_helper.rb b/app/helpers/books/reviews_helper.rb new file mode 100644 index 0000000..c87e6d2 --- /dev/null +++ b/app/helpers/books/reviews_helper.rb @@ -0,0 +1,16 @@ +module Books::ReviewsHelper + def rating_field(f) + content_tag :div, class: "flex flex-row-reverse justify-end items-center" do + (1..5).reverse_each.map do |rating| + f.radio_button(:rating, rating, class: "peer -ms-5 size-5 bg-transparent border-0 text-transparent cursor-pointer appearance-none checked:bg-none focus:bg-none focus:ring-0 focus:ring-offset-0") + + f.label("rating_#{rating}", class: "peer-checked:text-yellow-400 text-gray-300 pointer-events-none") do + inline_svg_tag("star.svg", class: "size-6 shrink-0") + end + end.join.html_safe + end + end + + def user_is_creator?(review) + review.user_id == current_user&.id + end +end diff --git a/app/helpers/books_helper.rb b/app/helpers/books_helper.rb index 7691bf3..aec3933 100644 --- a/app/helpers/books_helper.rb +++ b/app/helpers/books_helper.rb @@ -20,7 +20,7 @@ def render_stars(rating, max_stars = 5) def reviews_average(reviews) return if reviews.blank? - average = reviews.average(:rating) + average = reviews.average(:rating).round(2) content_tag :div, class: "flex items-center text-sm text-gray-500 mb-2" do concat(inline_svg_tag("star.svg", class: "size-5 text-yellow-500 mr-1")) concat("#{average} / 5") diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 6dcfebf..f90388a 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -1,5 +1,5 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder - INPUT_TYPES = [ :text_field, :email_field, :password_field, :number_field, :date_field ] + INPUT_TYPES = [ :text_field, :email_field, :password_field, :number_field, :date_field, :text_area ] INPUT_TYPES.each do |input_type| define_method(input_type) do |method, options = {}| diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index f421622..07ae407 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -20,6 +20,12 @@ export default class extends Controller { this.element.close(); } + closeOnSuccess(e) { + if (e.detail.success) { + this.close(); + } + } + reloadOnSuccess(e) { if (e.detail.success) { this.close(); diff --git a/app/models/book.rb b/app/models/book.rb index 880fe07..e4e77f8 100644 --- a/app/models/book.rb +++ b/app/models/book.rb @@ -2,7 +2,7 @@ class Book < ApplicationRecord include PgSearch::Model include ImageUploadable - has_many :reviews + has_many :reviews, dependent: :destroy validates :title, presence: true diff --git a/app/models/review.rb b/app/models/review.rb index 94495be..f6177da 100644 --- a/app/models/review.rb +++ b/app/models/review.rb @@ -7,6 +7,8 @@ class Review < ApplicationRecord before_validation :normalize_fields + default_scope { order(created_at: :desc) } + private def normalize_fields diff --git a/app/views/books/_book_description.html.erb b/app/views/books/_book_description.html.erb new file mode 100644 index 0000000..ff59263 --- /dev/null +++ b/app/views/books/_book_description.html.erb @@ -0,0 +1,8 @@ +<%# locals: (book:) -%> + +
+ <%= reviews_average(book.reviews) %> +

<%= book.title %>

+

<%= book.subtitle %>

+ <%= link_to "Leave a review", new_book_review_path(book), class: "px-4 py-2 mt-4 inline-block font-semibold text-sm bg-blue-500 text-white rounded-full shadow-sm", data: { turbo_frame: "modal" } if user_signed_in? %> +
diff --git a/app/views/books/_review.html.erb b/app/views/books/_review.html.erb deleted file mode 100644 index 3cbf1f6..0000000 --- a/app/views/books/_review.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -
- <%= render_stars(review.rating) %> -

<%= review.title %>

-

By <%= review.user.nickname %>

-

<%= review.description %>

-
diff --git a/app/views/books/reviews/_form.html.erb b/app/views/books/reviews/_form.html.erb new file mode 100644 index 0000000..2f78f14 --- /dev/null +++ b/app/views/books/reviews/_form.html.erb @@ -0,0 +1,26 @@ +<%# locals: (url:, submit_label:) -%> + +<%= modal({title: "Review: #{@book.title}"}) do %> + <%= modal_form_for(@review, url: url) do |f| %> + <%= alert_error(@review.errors.full_messages) %> + +
+ <%= f.label :rating %> + <%= rating_field(f) %> +
+ +
+ <%= f.label :title %> + <%= f.text_field :title, autofocus: true %> +
+ +
+ <%= f.label :description %> + <%= f.text_area :description %> +
+ +
+ <%= f.submit submit_label %> +
+ <% end %> +<% end %> diff --git a/app/views/books/reviews/_review.html.erb b/app/views/books/reviews/_review.html.erb new file mode 100644 index 0000000..a5a33f5 --- /dev/null +++ b/app/views/books/reviews/_review.html.erb @@ -0,0 +1,15 @@ +<%# locals: (review:) -%> + +<%= content_tag :div, class:"flex justify-between my-6 border-b pb-4", id: dom_id(review) do %> +
+ <%= render_stars(review.rating) %> +

<%= review.title %>

+

By <%= review.user.nickname %>

+

<%= review.description %>

+
+ <% if user_is_creator?(review) %> +
+ <%= link_to inline_svg_tag("pencil-square.svg", class: "size-6"), edit_book_review_path(@book, review), data: { turbo_frame: "modal" } %> +
+ <% end %> +<% end %> diff --git a/app/views/books/reviews/create.turbo_stream.erb b/app/views/books/reviews/create.turbo_stream.erb new file mode 100644 index 0000000..e7e8e63 --- /dev/null +++ b/app/views/books/reviews/create.turbo_stream.erb @@ -0,0 +1,8 @@ +<%= turbo_stream.prepend("reviews-list", partial: "review", locals: { review: @review }) %> +<%= turbo_stream.replace("book-description", partial: "books/book_description", locals: { book: @book }) %> +<%= turbo_stream.replace("empty-reviews-message") do %> + <% # In the case this is the first review we need to replace the "empty-reviews-message" element %> +
+ <%= render partial: "review", locals: { review: @review } %> +
+<% end %> diff --git a/app/views/books/reviews/edit.html.erb b/app/views/books/reviews/edit.html.erb new file mode 100644 index 0000000..50da000 --- /dev/null +++ b/app/views/books/reviews/edit.html.erb @@ -0,0 +1 @@ +<%= render partial: "form", locals: { url: book_review_path(@book, @review), submit_label: "Update review" } %> diff --git a/app/views/books/reviews/new.html.erb b/app/views/books/reviews/new.html.erb new file mode 100644 index 0000000..c797874 --- /dev/null +++ b/app/views/books/reviews/new.html.erb @@ -0,0 +1 @@ +<%= render partial: "form", locals: { url: book_reviews_path, submit_label: "Leave review" } %> diff --git a/app/views/books/reviews/update.turbo_stream.erb b/app/views/books/reviews/update.turbo_stream.erb new file mode 100644 index 0000000..e77b790 --- /dev/null +++ b/app/views/books/reviews/update.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.replace(dom_id(@review), partial: "review", locals: { review: @review }) %> +<%= turbo_stream.replace("book-description", partial: "books/book_description", locals: { book: @book }) %> diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb index 0d76440..38504a1 100644 --- a/app/views/books/show.html.erb +++ b/app/views/books/show.html.erb @@ -4,20 +4,18 @@
<%= book_image(@book, "max-h-64 rounded-lg shadow-lg") %>
-
- <%= reviews_average(@reviews) %> -

<%= @book.title %>

-

<%= @book.subtitle %>

-
+ <%= render partial: "book_description", locals: { book: @book } %>

Reviews

<% if @reviews.any? %> - <%= render partial: "review", collection: @reviews %> - <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
+ <%= render partial: "books/reviews/review", collection: @reviews %> + <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
<% else %> -

No reviews yet. Be the first to leave a review!

+

No reviews yet. Be the first to leave a review!

<% end %>
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index fd361b4..dab0ab3 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -1,5 +1,5 @@ <%= modal({title: "Forgot your password?"}) do %> - <%= modal_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :post }) do |f| %> + <%= modal_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :post }, data: { action: "turbo:submit-end->modal#reloadOnSuccess" }) do |f| %> <%= alert_error(resource.errors.full_messages, I18n.t("errors.messages.not_saved", count: resource.errors.count, resource: resource.class.model_name.human.downcase)) %>
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 7ffe933..82a700b 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -1,5 +1,5 @@ <%= modal({title: "Sign up"}) do %> - <%= modal_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> + <%= modal_form_for(resource, as: resource_name, url: registration_path(resource_name), data: { action: "turbo:submit-end->modal#reloadOnSuccess" }) do |f| %> <%= alert_error(resource.errors.full_messages, I18n.t("errors.messages.not_saved", count: resource.errors.count, resource: resource.class.model_name.human.downcase)) %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 212cfec..4f1e004 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,5 +1,5 @@ <%= modal({title: "Log in"}) do %> - <%= modal_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> + <%= modal_form_for(resource, as: resource_name, url: session_path(resource_name), data: { action: "turbo:submit-end->modal#reloadOnSuccess" }) do |f| %> <%= alert_error(flash[:alert]) %>
diff --git a/config/routes.rb b/config/routes.rb index 006caeb..207ea77 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,5 +14,7 @@ # Defines the root path route ("/") root "books#index" - resources :books, only: [ :show ] + resources :books, only: [ :show ] do + resources :reviews, only: [ :new, :create, :edit, :update ], module: :books + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a36a0ab..fc9ffe4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,6 +6,7 @@ abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +require 'support/devise' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are diff --git a/spec/requests/books/reviews_spec.rb b/spec/requests/books/reviews_spec.rb new file mode 100644 index 0000000..a173119 --- /dev/null +++ b/spec/requests/books/reviews_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe "Books::Reviews", type: :request do + let(:book) { FactoryBot.create(:book) } + let(:user) { FactoryBot.create(:user) } + let(:review) { FactoryBot.create(:review, user:) } + + describe "GET /books/:book_id/reviews/new" do + it "render new review page" do + sign_in user + get new_book_review_path(book) + expect(response).to have_http_status(:success) + expect(response).to render_template(:new) + end + end + + describe "POST /books/:book_id/reviews" do + let(:review_params) { { review: { title: "Great book", description: "I enjoyed it!", rating: 5 } } } + + context "when format is HTML" do + it "creates a new review and redirects to the book page" do + sign_in user + post book_reviews_path(book), params: review_params + expect(response).to redirect_to(book_path(book)) + end + end + + context "when format is turbo_stream" do + it "creates a new review and renders the Turbo Stream response" do + sign_in user + post book_reviews_path(book), params: review_params, as: :turbo_stream + expect(response).to have_http_status(:success) + expect(response).to render_template(:create) + end + end + + it "renders the new template if the review creation fails" do + sign_in user + post book_reviews_path(book), params: { review: { title: "Great book" } } + expect(response).to have_http_status(:unprocessable_entity) + expect(response).to render_template(:new) + end + end + + describe "GET /books/:book_id/reviews/:id/edit" do + it "render edit review page" do + sign_in user + get edit_book_review_path(review.book, review) + expect(response).to have_http_status(:success) + expect(response).to render_template(:edit) + end + end + + describe "PATCH /books/:book_id/reviews/:id" do + let(:review_params) { { review: { title: "New title" } } } + + context "when format is HTML" do + it "updates a the review and redirects to the book page" do + sign_in user + patch book_review_path(review.book, review), params: review_params + review.reload + expect(review.title).to eq(review_params[:review][:title]) + expect(response).to redirect_to(book_path(review.book)) + end + end + + context "when format is turbo_stream" do + it "updates a the review and renders the Turbo Stream response" do + sign_in user + patch book_review_path(review.book, review), params: review_params, as: :turbo_stream + review.reload + expect(review.title).to eq(review_params[:review][:title]) + expect(response).to have_http_status(:success) + expect(response).to render_template(:update) + end + end + + it "renders the edit template if the review update fails" do + sign_in user + patch book_review_path(review.book, review), params: { review: { title: "" } } + expect(response).to have_http_status(:unprocessable_entity) + expect(response).to render_template(:edit) + end + end + + context "authorization" do + let(:user2) { FactoryBot.create(:user) } + + it "redirects non-owner user to the book page when trying to edit a review" do + sign_in user2 + get edit_book_review_path(review.book, review) + expect(response).to redirect_to book_path(review.book) + end + + it "redirects the user if they try to update a review they do not own" do + sign_in user2 + patch book_review_path(review.book, review), params: { review: { title: "New Title" } } + expect(response).to redirect_to book_path(review.book) + end + end + + context "authentication" do + it "allows logged in user to access the new review page" do + sign_in user + get new_book_review_path(book) + expect(response).to have_http_status(:success) + end + + it "redirect if the user is not logged in and want to leave a review" do + get new_book_review_path(book) + expect(response).to redirect_to new_user_session_path + end + end +end diff --git a/spec/support/devise.rb b/spec/support/devise.rb new file mode 100644 index 0000000..6b14bae --- /dev/null +++ b/spec/support/devise.rb @@ -0,0 +1,5 @@ +RSpec.configure do |config| + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :view + config.include Devise::Test::IntegrationHelpers, type: :request +end