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 3f6cf7f commit f3a109b
Show file tree
Hide file tree
Showing 17 changed files with 319 additions and 49 deletions.
87 changes: 41 additions & 46 deletions app/components/project_submissions/item_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,52 +1,47 @@
<%= turbo_frame_tag project_submission do %>
<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 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>
</div>

<div class="flex flex-col md:flex-row md:items-center">
<%= link_to 'View code', project_submission.repo_url, target: '_blank', rel: 'noreferrer', class: 'button button--gray font-semibold md:mr-4', data: { test_id: 'view-code-btn' } %>
<%= link_to 'Live preview', project_submission.live_preview_url, target: '_blank', rel: 'noreferrer', class: 'button button--gray font-semibold mt-5 md:mt-0 md:mr-4', data: { test_id: 'live-preview-btn' } %>

<div class="relative flex-none" data-controller="visibility" data-action="visibility:click:outside->visibility#off" data-visibility-visible-value="false">
<button type="button" data-action="click->visibility#toggle" data-test-id="submission-action-menu-btn" class="-m-2.5 block p-2.5 text-gray-500 hover:text-gray-900" id="options-menu-0-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open options</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z" />
</svg>
</button>

<div
data-visibility-target="content"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-start="transform opacity-0 scale-95"
data-transition-enter-end="transform opacity-100 scale-100"
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">

<% 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 %>
<%= inline_svg_tag 'icons/pencil-square.svg', class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500', aria: true, title: 'edit', desc: 'edit icon' %>
Edit
<% end %>
<div class="flex items-center mb-4 md:mb-0">
<%= render ProjectSubmissions::LikeComponent.new(project_submission) %>
<p class="truncate max-w-xs lg:max-w-lg font-medium text-lg break-words"><%= project_submission.user.username %></p>
</div>

<%= link_to 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_method: :delete, turbo_confirm: 'Are you sure? this cannot be undone.', test_id: 'delete-submission' } do %>
<%= inline_svg_tag 'icons/trash.svg', class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500', aria: true, title: 'edit', desc: 'edit icon' %>
Delete
<div class="flex flex-col md:flex-row md:items-center">
<%= link_to 'View code', project_submission.repo_url, target: '_blank', rel: 'noreferrer', class: 'button button--gray font-semibold md:mr-4', data: { test_id: 'view-code-btn' } %>
<%= link_to 'Live preview', project_submission.live_preview_url, target: '_blank', rel: 'noreferrer', class: 'button button--gray font-semibold mt-5 md:mt-0 md:mr-4', data: { test_id: 'live-preview-btn' } %>

<div class="relative flex-none" data-controller="visibility" data-action="visibility:click:outside->visibility#off" data-visibility-visible-value="false">
<button type="button" data-action="click->visibility#toggle" data-test-id="submission-action-menu-btn" class="-m-2.5 block p-2.5 text-gray-500 hover:text-gray-900" id="options-menu-0-button" aria-expanded="false" aria-haspopup="true">
<span class="sr-only">Open options</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z" />
</svg>
</button>

<div
data-visibility-target="content"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-start="transform opacity-0 scale-95"
data-transition-enter-end="transform opacity-100 scale-100"
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="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 %>
<%= inline_svg_tag 'icons/pencil-square.svg', class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500', aria: true, title: 'edit', desc: 'edit icon' %>
Edit
<% end %>
<%= link_to 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_method: :delete, turbo_confirm: 'Are you sure? this cannot be undone.', test_id: 'delete-submission' } do %>
<%= inline_svg_tag 'icons/trash.svg', class: 'mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500', aria: true, title: 'edit', desc: 'edit icon' %>
Delete
<% end %>
<% end %>
<% end %>
</div>
</div>
</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 @@ -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
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 @@ -23,7 +23,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[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
Loading

0 comments on commit f3a109b

Please sign in to comment.