From 0302b1cf829e8c15f284b72db80e6f4c60b0f40a Mon Sep 17 00:00:00 2001 From: Kevin Mulhern Date: Tue, 11 Jul 2023 21:14:30 +0100 Subject: [PATCH] Feature: Liking project submissions with Hotwire (#3929) 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 | 14 ++-- .../project_submissions/item_component.rb | 8 +++ .../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 | 43 ++++++++++++ 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 + package.json | 1 + spec/models/project_submission_spec.rb | 51 ++++++++++++++ .../like_spec.rb | 70 +++++++++++++++++++ yarn.lock | 5 ++ 17 files changed, 282 insertions(+), 13 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 040e9bb327..a3ff6d2f9d 100644 --- a/app/components/project_submissions/item_component.html.erb +++ b/app/components/project_submissions/item_component.html.erb @@ -1,14 +1,9 @@ -<%= turbo_frame_tag project_submission do %> +
- - - -

<%= project_submission.user.username %>

+ <%= render ProjectSubmissions::LikeComponent.new(project_submission) %> +

<%= project_submission.user.username %>

@@ -49,7 +44,6 @@ <% end %>
-
-<% end %> + diff --git a/app/components/project_submissions/item_component.rb b/app/components/project_submissions/item_component.rb index 47cf993067..02989597c2 100644 --- a/app/components/project_submissions/item_component.rb +++ b/app/components/project_submissions/item_component.rb @@ -1,5 +1,7 @@ module ProjectSubmissions class ItemComponent < ApplicationComponent + CURRENT_USER_SORT_CODE = 10_000_000 # current user's submission should always be first + with_collection_parameter :project_submission def initialize(project_submission:, current_user:) @@ -14,5 +16,11 @@ def render? private attr_reader :project_submission, :current_user + + def sort_code + return CURRENT_USER_SORT_CODE if project_submission.user == current_user + + project_submission.cached_votes_total + 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 ac55f8b8ab..6366bdd9a2 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 @@ -58,6 +58,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..b800d95630 --- /dev/null +++ b/app/javascript/controllers/sort_controller.js @@ -0,0 +1,43 @@ +import { Controller } from '@hotwired/stimulus'; +import Sortable from 'sortablejs'; + +export default class extends Controller { + static targets = ['item']; + + connect() { + this.sortable = Sortable.create(this.element, { + animation: 300, + easing: 'cubic-bezier(0.61, 1, 0.88, 1)', + disabled: true, + }); + } + + itemTargetConnected() { + const items = Array.from(this.itemTargets); + + if (this.itemsAreSorted(items)) return; + + const sortedItems = items.sort((a, b) => this.compareItems(a, b)).map((item) => item.dataset.id); + this.sortable.sort(sortedItems, true); + } + + 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 69fd35e868..042c0b8609 100644 --- a/app/views/lessons/v2_project_submissions/index.html.erb +++ b/app/views/lessons/v2_project_submissions/index.html.erb @@ -25,7 +25,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 c52a28f2d1..28053b5e2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -101,6 +101,7 @@ resources :project_submissions do resources :flags, only: %i[new 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/package.json b/package.json index f5bae234f3..9b87033422 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "sass": "^1.63.4", "sass-loader": "^13.3.2", "shakapacker": "6.6", + "sortablejs": "^1.15.0", "stickyfilljs": "^2.1.0", "stimulus-use": "^0.51.3", "stimulus-validation": "^1.0.1-beta.3", 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 diff --git a/yarn.lock b/yarn.lock index 81455e752f..14cab59bc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7688,6 +7688,11 @@ sockjs@^0.3.24: uuid "^8.3.2" websocket-driver "^0.7.4" +sortablejs@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" + integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"