From 46937005578426c595e5631dc7ec029317240f44 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sat, 8 Jul 2023 00:04:03 +0100 Subject: [PATCH] Feature: Liking project submissions with Hotwire Because: * We are moving our React components to Hotwire This commit: * Add like view component to encapsulate the like button. * Add like singular resource route and controller - now with a proper delete route instead of reusing the create action. * Decorates submissions with current user likes when they are being fetched. This saves us from checking each one individually and hitting the database each time. * Adds a reusable sorting stimulus controller - can be used to automatically sort lists on page load or when a list item changes. --- .../item_component.html.erb | 11 +-- .../project_submissions/item_component.rb | 6 ++ .../like_component.html.erb | 6 ++ .../project_submissions/like_component.rb | 19 +++++ .../v2_project_submissions_controller.rb | 7 +- .../v2_likes_controller.rb | 21 ++++++ app/javascript/controllers/sort_controller.js | 42 +++++++++++ app/models/project_submission.rb | 14 ++++ .../project_submissions/mark_liked.rb | 28 ++++++++ .../v2_project_submissions/index.html.erb | 2 +- .../v2_likes/create.turbo_stream.erb | 3 + config/initializers/pagy.rb | 2 +- config/routes.rb | 1 + spec/models/project_submission_spec.rb | 51 ++++++++++++++ .../like_spec.rb | 70 +++++++++++++++++++ 15 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 app/components/project_submissions/like_component.html.erb create mode 100644 app/components/project_submissions/like_component.rb create mode 100644 app/controllers/project_submissions/v2_likes_controller.rb create mode 100644 app/javascript/controllers/sort_controller.js create mode 100644 app/services/project_submissions/mark_liked.rb create mode 100644 app/views/project_submissions/v2_likes/create.turbo_stream.erb create mode 100644 spec/system/v2_lesson_project_submissions/like_spec.rb diff --git a/app/components/project_submissions/item_component.html.erb b/app/components/project_submissions/item_component.html.erb index 46f2efd718..a040cda405 100644 --- a/app/components/project_submissions/item_component.html.erb +++ b/app/components/project_submissions/item_component.html.erb @@ -1,13 +1,8 @@ -<%= turbo_frame_tag project_submission do %> +<%= turbo_frame_tag project_submission, data: { sort_target: 'item', sort_code: sort_by } do %>
- - - + <%= render ProjectSubmissions::LikeComponent.new(project_submission) %>

<%= project_submission.user.username %>

@@ -31,7 +26,7 @@ data-transition-leave="transition ease-in duration-75" data-transition-leave-start="transform opacity-100 scale-100" data-transition-leave-end="transform opacity-10 scale-95" - class="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="options-menu-0-button" tabindex="-1"> + class="hidden absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="options-menu-0-button" tabindex="-1"> <% if project_submission.user == current_user %> <%= link_to edit_lesson_v2_project_submission_path(project_submission.lesson, project_submission), class: 'text-gray-700 group flex items-center px-4 py-2 text-sm', role: 'menuitem', tabindex: '-1', data: { turbo_frame: 'modal', test_id: 'edit-submission'} do %> diff --git a/app/components/project_submissions/item_component.rb b/app/components/project_submissions/item_component.rb index 47cf993067..2313f32e66 100644 --- a/app/components/project_submissions/item_component.rb +++ b/app/components/project_submissions/item_component.rb @@ -14,5 +14,11 @@ def render? private attr_reader :project_submission, :current_user + + def sort_by + return 9999 if project_submission.user == current_user + + (project_submission.cached_votes_total + 1) * 10 + end end end diff --git a/app/components/project_submissions/like_component.html.erb b/app/components/project_submissions/like_component.html.erb new file mode 100644 index 0000000000..ec948e01c7 --- /dev/null +++ b/app/components/project_submissions/like_component.html.erb @@ -0,0 +1,6 @@ +<%= turbo_frame_tag dom_id(project_submission, :likes) do %> + <%= button_to project_submission_v2_like_path(project_submission), method: http_action, class: 'text-gray-400 mr-4 flex items-center hint--top', data: { test_id: 'like-submission' }, aria: { label: 'Like submission' } do %> + <%= project_submission.cached_votes_total %> + <%= inline_svg_tag 'icons/heart.svg', class: "h-5 w-5 #{bg_color_class}", aria: true, title: 'heart', desc: 'heart icon' %> + <% end %> +<% end %> diff --git a/app/components/project_submissions/like_component.rb b/app/components/project_submissions/like_component.rb new file mode 100644 index 0000000000..a772b33c33 --- /dev/null +++ b/app/components/project_submissions/like_component.rb @@ -0,0 +1,19 @@ +module ProjectSubmissions + class LikeComponent < ApplicationComponent + def initialize(project_submission) + @project_submission = project_submission + end + + private + + attr_reader :project_submission + + def http_action + project_submission.liked? ? :delete : :post + end + + def bg_color_class + project_submission.liked? ? 'text-teal-700' : 'text-gray-400' + end + end +end diff --git a/app/controllers/lessons/v2_project_submissions_controller.rb b/app/controllers/lessons/v2_project_submissions_controller.rb index bd90a8f4ce..13754c2184 100644 --- a/app/controllers/lessons/v2_project_submissions_controller.rb +++ b/app/controllers/lessons/v2_project_submissions_controller.rb @@ -5,7 +5,7 @@ class V2ProjectSubmissionsController < ApplicationController def index @current_user_submission = current_user.project_submissions.find_by(lesson: @lesson) - @pagy, @project_submissions = pagy(project_submissions_query.public_submissions, items: params.fetch(:limit, 15)) + @pagy, @project_submissions = pagy_array(project_submissions_query, items: params.fetch(:limit, 15)) end def new @@ -56,6 +56,11 @@ def project_submissions_query lesson: @lesson, current_user: ) + + ProjectSubmissions::MarkLiked.call( + user: current_user, + project_submissions: @project_submissions_query.public_submissions + ) end def set_lesson diff --git a/app/controllers/project_submissions/v2_likes_controller.rb b/app/controllers/project_submissions/v2_likes_controller.rb new file mode 100644 index 0000000000..db1dd26772 --- /dev/null +++ b/app/controllers/project_submissions/v2_likes_controller.rb @@ -0,0 +1,21 @@ +class ProjectSubmissions::V2LikesController < ApplicationController + before_action :authenticate_user! + + def create + @project_submission = ProjectSubmission.find(params[:project_submission_id]) + @project_submission.like!(current_user) + + respond_to do |format| + format.turbo_stream + end + end + + def destroy + @project_submission = ProjectSubmission.find(params[:project_submission_id]) + @project_submission.unlike!(current_user) + + respond_to do |format| + format.turbo_stream { render :create } + end + end +end diff --git a/app/javascript/controllers/sort_controller.js b/app/javascript/controllers/sort_controller.js new file mode 100644 index 0000000000..a0819fc641 --- /dev/null +++ b/app/javascript/controllers/sort_controller.js @@ -0,0 +1,42 @@ +import { Controller } from '@hotwired/stimulus'; +import { useTargetMutation } from 'stimulus-use'; + +export default class extends Controller { + static targets = ['item']; + + connect() { + useTargetMutation(this); + this.sortItems(); + } + + itemTargetAdded() { + this.sortItems(); + } + + sortItems() { + const items = Array.from(this.itemTargets); + + if (this.itemsAreSorted(items)) return; + items.sort((a, b) => this.compareItems(a, b)).forEach((child) => this.element.append(child)); + } + + itemsAreSorted() { + return this.itemSortCodes().every((sortCode, index, items) => { + if (index === 0) return true; + return sortCode <= items[index - 1]; + }); + } + + itemSortCodes() { + return this.itemTargets.map((item) => this.getSortCode(item)); + } + + /* eslint-disable class-methods-use-this */ + getSortCode(item) { + return parseFloat(item.getAttribute('data-sort-code')) || 0; + } + + compareItems(left, right) { + return this.getSortCode(right) - this.getSortCode(left); + } +} diff --git a/app/models/project_submission.rb b/app/models/project_submission.rb index 53326c8793..425fccaa13 100644 --- a/app/models/project_submission.rb +++ b/app/models/project_submission.rb @@ -22,6 +22,20 @@ class ProjectSubmission < ApplicationRecord scope :created_today, -> { where('created_at >= ?', Time.zone.now.beginning_of_day) } scope :discardable, -> { not_removed_by_admin.where('discard_at <= ?', Time.zone.now) } + attribute :liked, :boolean, default: false + + def like!(user = nil) + liked_by(user) if user + + self.liked = true + end + + def unlike!(user = nil) + unliked_by(user) if user + + self.liked = false + end + private def live_preview_allowed diff --git a/app/services/project_submissions/mark_liked.rb b/app/services/project_submissions/mark_liked.rb new file mode 100644 index 0000000000..32d3d8f864 --- /dev/null +++ b/app/services/project_submissions/mark_liked.rb @@ -0,0 +1,28 @@ +module ProjectSubmissions + class MarkLiked + def initialize(user:, project_submissions:) + @user = user + @project_submissions = project_submissions + end + + def self.call(**args) + new(**args).call + end + + def call + project_submissions.each do |submission| + next if liked_submission_ids.exclude?(submission.id) + + submission.like! + end + end + + private + + attr_reader :user, :project_submissions + + def liked_submission_ids + @liked_submission_ids ||= user.votes.where(votable: project_submissions).pluck(:votable_id) + end + end +end diff --git a/app/views/lessons/v2_project_submissions/index.html.erb b/app/views/lessons/v2_project_submissions/index.html.erb index 9ee9496f06..478083a63c 100644 --- a/app/views/lessons/v2_project_submissions/index.html.erb +++ b/app/views/lessons/v2_project_submissions/index.html.erb @@ -23,7 +23,7 @@ <% end %> - <%= turbo_frame_tag 'submissions-list', data: { test_id: 'submissions-list' } do %> + <%= turbo_frame_tag 'submissions-list', data: { test_id: 'submissions-list', controller: 'sort' } do %> <%= render ProjectSubmissions::ItemComponent.new(project_submission: @current_user_submission, current_user:) %> <%= render ProjectSubmissions::ItemComponent.with_collection(@project_submissions, current_user:) %> <% end %> diff --git a/app/views/project_submissions/v2_likes/create.turbo_stream.erb b/app/views/project_submissions/v2_likes/create.turbo_stream.erb new file mode 100644 index 0000000000..c6650f5814 --- /dev/null +++ b/app/views/project_submissions/v2_likes/create.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace @project_submission do %> + <%= render ProjectSubmissions::ItemComponent.new(project_submission: @project_submission, current_user: current_user) %> +<% end %> diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb index 896a191964..eb76488aa7 100644 --- a/config/initializers/pagy.rb +++ b/config/initializers/pagy.rb @@ -33,7 +33,7 @@ # Array extra: Paginate arrays efficiently, avoiding expensive array-wrapping and without overriding # See https://ddnexus.github.io/pagy/extras/array -# require 'pagy/extras/array' +require 'pagy/extras/array' # Calendar extra: Add pagination filtering by calendar time unit (year, quarter, month, week, day) # See https://ddnexus.github.io/pagy/extras/calendar diff --git a/config/routes.rb b/config/routes.rb index 71f91be328..79f275f3f3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -101,6 +101,7 @@ resources :project_submissions do resources :flags, only: %i[create], controller: 'project_submissions/flags' resources :likes, controller: 'project_submissions/likes' + resource :v2_like, only: %i[create destroy], controller: 'project_submissions/v2_likes' end resources :paths, only: %i[index show] do diff --git a/spec/models/project_submission_spec.rb b/spec/models/project_submission_spec.rb index cacb6d6b68..b28b1e1aee 100644 --- a/spec/models/project_submission_spec.rb +++ b/spec/models/project_submission_spec.rb @@ -153,4 +153,55 @@ end end end + + describe '#liked' do + it 'return false by default' do + expect(project_submission).not_to be_liked + end + + context 'when the project submission is liked' do + it 'returns true' do + project_submission.liked = true + expect(project_submission).to be_liked + end + end + + context 'when the project submission is not liked' do + it 'returns false' do + project_submission.liked = false + expect(project_submission).not_to be_liked + end + end + end + + describe '#like!' do + it 'marks the project submission as liked' do + user = create(:user) + expect { project_submission.like!(user) }.to change { project_submission.liked }.from(false).to(true) + end + + context 'when a user is provided' do + it 'creates a like for the user' do + expect { project_submission.like!(create(:user)) }.to change { project_submission.votes_for.count }.by(1) + end + end + end + + describe '#unlike!' do + it 'marks the project submission as unliked' do + user = create(:user) + project_submission.liked = true + expect { project_submission.unlike!(user) }.to change { project_submission.liked }.from(true).to(false) + end + + context 'when a user has been provided' do + it 'removes the users like' do + user = create(:user) + + project_submission.like!(user) + + expect { project_submission.unlike!(user) }.to change { project_submission.votes_for.count }.by(-1) + end + end + end end diff --git a/spec/system/v2_lesson_project_submissions/like_spec.rb b/spec/system/v2_lesson_project_submissions/like_spec.rb new file mode 100644 index 0000000000..904573fb2f --- /dev/null +++ b/spec/system/v2_lesson_project_submissions/like_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe 'Liking project submissions' do + let(:user) { create(:user) } + let(:lesson) { create(:lesson, :project) } + + context "when liking other users' submissions" do + before do + Flipper.enable(:v2_project_submissions) + create(:project_submission, lesson:) + + sign_in(user) + visit lesson_path(lesson) + end + + after do + Flipper.disable(:v2_project_submissions) + end + + it 'you can like another users submission' do + within(:test_project_submission, 1) do + expect(find(:test_id, 'like-count')).to have_content('0') + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('1') + end + end + + it 'you can unlike another users submission' do + within(:test_project_submission, 1) do + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('1') + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('0') + end + end + end + + context 'when liking your own submission' do + before do + Flipper.enable(:v2_project_submissions) + create(:project_submission, lesson:, user:) + + sign_in(user) + visit lesson_path(lesson) + end + + after do + Flipper.disable(:v2_project_submissions) + end + + it 'you can like your submission' do + within(:test_project_submission, 1) do |submission| + expect(submission).to have_content(user.username) + expect(find(:test_id, 'like-count')).to have_content('0') + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('1') + end + end + + it 'you can unlike your submission' do + within(:test_project_submission, 1) do |submission| + expect(submission).to have_content(user.username) + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('1') + find(:test_id, 'like-submission').click + expect(find(:test_id, 'like-count')).to have_content('0') + end + end + end +end