Skip to content

Commit

Permalink
Feature: Liking project submissions with Hotwire
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
KevinMulhern committed Jul 10, 2023
1 parent 1804e6e commit f3a5829
Show file tree
Hide file tree
Showing 17 changed files with 282 additions and 13 deletions.
14 changes: 4 additions & 10 deletions app/components/project_submissions/item_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
<%= turbo_frame_tag project_submission do %>
<div id="<%= dom_id(project_submission) %>" data-id="<%= project_submission.id %>" data-sort-target="item" data-sort-code="<%= sort_code %>">
<div data-test-id="submission-item" class="relative py-6 border-solid border-t border-gray-300 flex flex-col md:flex-row justify-between items-center">

<div class="flex items-center mb-4 md:mb-0">

<button class="text-gray-300 mr-4 flex items-center hint--top" data-test-id="like-btn" aria-label="Like submission">
<span class="mr-1" data-test-id="number-of-likes"><%= project_submission.votes_for.size %></span>
<%= inline_svg_tag 'icons/heart.svg', class: 'h-5 w-5', aria: true, title: 'heart', desc: 'heart icon' %>
</button>

<p class="truncate max-w-xs lg:max-w-lg font-medium text-lg break-words"><%= project_submission.user.username %></p>
<%= render ProjectSubmissions::LikeComponent.new(project_submission) %>
<p class="truncate max-w-xs lgs:max-w-lg font-medium text-lg break-words"><%= project_submission.user.username %></p>
</div>

<div class="flex flex-col md:flex-row md:items-center">
Expand Down Expand Up @@ -49,7 +44,6 @@
<% end %>
</div>
</div>

</div>
</div>
<% end %>
</div>
8 changes: 8 additions & 0 deletions app/components/project_submissions/item_component.rb
Original file line number Diff line number Diff line change
@@ -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:)
Expand All @@ -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
6 changes: 6 additions & 0 deletions app/components/project_submissions/like_component.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
<span class="mr-1" data-test-id="like-count"><%= project_submission.cached_votes_total %></span>
<%= inline_svg_tag 'icons/heart.svg', class: "h-5 w-5 #{bg_color_class}", aria: true, title: 'heart', desc: 'heart icon' %>
<% end %>
<% end %>
19 changes: 19 additions & 0 deletions app/components/project_submissions/like_component.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion app/controllers/lessons/v2_project_submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/controllers/project_submissions/v2_likes_controller.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions app/javascript/controllers/sort_controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 14 additions & 0 deletions app/models/project_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions app/services/project_submissions/mark_liked.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/views/lessons/v2_project_submissions/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<% end %>
</header>

<%= 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 %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= turbo_stream.replace @project_submission do %>
<%= render ProjectSubmissions::ItemComponent.new(project_submission: @project_submission, current_user: current_user) %>
<% end %>
2 changes: 1 addition & 1 deletion config/initializers/pagy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions spec/models/project_submission_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions spec/system/v2_lesson_project_submissions/like_spec.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f3a5829

Please sign in to comment.