Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backend: Basic plumbing for bookmark feature #4308

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/components/bookmarks/button_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="flex items-center justify-center" id="bookmark-button">
<%= button_to create_or_destroy_path,
form_class: 'w-full h-full',
method: bookmarked? ? :delete : :post,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would have preferred to keep the logic in the VC class with the rest of it, but putting it in a method didn't work for some reason 🤷

data: { test_id: 'bookmark-button' },
params: { lesson_id: lesson.id },
class: 'button button--secondary h-[54px] sm:h-full w-full hover:bg-teal-700' do %>
<span class="flex items-center"><i class='<%= icon %>'></i></span>
<% end %>
</div>
29 changes: 29 additions & 0 deletions app/components/bookmarks/button_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class Bookmarks::ButtonComponent < ApplicationComponent
def initialize(lesson:, bookmark:, current_user: nil)
@lesson = lesson
@current_user = current_user
@bookmark = bookmark
end

private

attr_reader :lesson, :current_user, :bookmark

def render?
return false unless current_user

Feature.enabled?(:bookmarks, current_user)
end

def bookmarked?
bookmark.present?
end

def icon
bookmarked? ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'
end

def create_or_destroy_path
bookmarked? ? users_bookmark_path(bookmark) : users_bookmarks_path
end
end
17 changes: 15 additions & 2 deletions app/controllers/lessons_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
class LessonsController < ApplicationController
before_action :set_cache_control_header_to_no_store
before_action :set_lesson
before_action :set_bookmark

def show
@lesson = Lesson.find(params[:id])

if user_signed_in?
Courses::MarkCompletedLessons.call(user: current_user, lessons: Array(@lesson))
end
end

private

def set_lesson
@lesson = Lesson.find(params[:id])
end

def set_bookmark
return unless current_user
return unless Feature.enabled?(:bookmarks, current_user)

@bookmark = current_user.bookmarks.find_by(lesson_id: @lesson.id)
end
end
50 changes: 50 additions & 0 deletions app/controllers/users/bookmarks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
class Users::BookmarksController < ApplicationController
before_action :authenticate_user!
before_action :set_lesson, only: %i[create destroy]

def index
@bookmarks = current_user.bookmarks
@lessons = current_user.bookmarked_lessons
end

def new
@bookmark = Bookmark.new
end

def create
respond_to do |format|
@bookmark = current_user.bookmarks.build(lesson: @lesson)

if @bookmark.save
format.turbo_stream { flash.now[:notice] = create_or_destroy_bookmark_helper }
format.html { redirect_to({ action: 'index' }, notice: create_or_destroy_bookmark_helper) }
else
format.html { redirect_back status: :unprocessable_entity, alert: 'Unable to create bookmark' }
end
end
end

def destroy
respond_to do |format|
@bookmark = Bookmark.find(params[:id])
@bookmark.destroy

format.turbo_stream { flash.now[:notice] = create_or_destroy_bookmark_helper(destroy: true) }
format.html { redirect_to({ action: 'index' }, notice: create_or_destroy_bookmark_helper(destroy: true)) }
end
end

private

def set_lesson
@lesson = Lesson.find(params[:lesson_id])
end

# HACK: Temp method for easier prototyping
def create_or_destroy_bookmark_helper(destroy: false)
<<~FLASH.html_safe # rubocop:disable Rails/OutputSafety
Bookmark #{destroy ? 'removed' : 'created'}!
#{helpers.link_to 'Click to see saved bookmarks', users_bookmarks_path}
FLASH
end
end
8 changes: 8 additions & 0 deletions app/models/bookmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Bookmark < ApplicationRecord
belongs_to :user
belongs_to :lesson

validates :lesson, uniqueness: { scope: :user, message: 'user has already bookmarked this lesson' }

delegate :title, to: :lesson
end
1 change: 1 addition & 0 deletions app/models/lesson.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Lesson < ApplicationRecord
has_one :content, dependent: :destroy
has_many :project_submissions, dependent: :destroy
has_many :lesson_completions, dependent: :destroy
has_many :bookmarks, dependent: :destroy
has_many :completing_users, through: :lesson_completions, source: :user

scope :most_recent_updated_at, -> { maximum(:updated_at) }
Expand Down
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class User < ApplicationRecord

has_many :lesson_completions, dependent: :destroy
has_many :completed_lessons, through: :lesson_completions, source: :lesson
has_many :bookmarks, dependent: :destroy
has_many :bookmarked_lessons, through: :bookmarks, source: :lesson
has_many :project_submissions, dependent: :destroy
has_many :user_providers, dependent: :destroy
has_many :flags, foreign_key: :flagger_id, dependent: :destroy, inverse_of: :flagger
Expand Down
1 change: 1 addition & 0 deletions app/views/lessons/_lesson_buttons.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<% end %>

<% if user_signed_in? %>
<%= render Bookmarks::ButtonComponent.new(lesson:, bookmark:, current_user:) %>
<%= render Complete::ButtonComponent.new(lesson:) %>
<% else %>
<%= link_to(
Expand Down
2 changes: 1 addition & 1 deletion app/views/lessons/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
</p>
<% end %>

<%= render 'lesson_buttons', lesson: @lesson, course: @lesson.course, user: @user %>
<%= render 'lesson_buttons', lesson: @lesson, course: @lesson.course, user: @user, bookmark: @bookmark %>
<% end %>
<% end %>
</div>
Expand Down
5 changes: 5 additions & 0 deletions app/views/users/bookmarks/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= turbo_stream.replace 'bookmark-button' do %>
<%= render Bookmarks::ButtonComponent.new(lesson: @lesson, current_user:, bookmark: @bookmark) %>
<% end %>

<%= turbo_stream.update 'flash-messages', partial: 'shared/flash' %>
5 changes: 5 additions & 0 deletions app/views/users/bookmarks/destroy.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= turbo_stream.replace 'bookmark-button' do %>
<%= render Bookmarks::ButtonComponent.new(lesson: @lesson, current_user:, bookmark: nil) %>
<% end %>

<%= turbo_stream.update 'flash-messages', partial: 'shared/flash' %>
16 changes: 16 additions & 0 deletions app/views/users/bookmarks/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

<div class="page-container">
<div class="max-w-4xl mx-auto">
<h1 class="page-heading-title">My Bookmarked Lessons</h1>
<section class="mb-24" data-test-id="bookmarks">
<div class="text-center mt-0 mb-6 mx-auto">
<% @lessons.each do|lesson| %>
<div>
<icon class="fa-solid fa-bookmark"></icon>
<%= link_to lesson.title, lesson %>
</div>
<% end %>
</div>
</section>
</div>
</div>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
resources :progress, only: :destroy
resources :project_submissions, only: %i[edit update]
resource :profile, only: %i[edit update]
resources :bookmarks, only: %i[index new create destroy]
end

namespace :lessons do
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20231231013215_create_bookmarks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateBookmarks < ActiveRecord::Migration[7.0]
def change
create_table :bookmarks do |t|
t.belongs_to :lesson, null: false, foreign_key: true
t.belongs_to :user, null: false, foreign_key: true

t.timestamps

t.index %i[user_id lesson_id], unique: true
end
end
end
14 changes: 13 additions & 1 deletion db/schema.rb

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

6 changes: 6 additions & 0 deletions spec/factories/bookmarks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :bookmark do
user
lesson
end
end
23 changes: 23 additions & 0 deletions spec/models/bookmark_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require 'rails_helper'

RSpec.describe Bookmark do
subject(:bookmark) { create(:bookmark, lesson:) }

let!(:lesson) { create(:lesson, section: create(:section, course:), course:) }
let!(:course) { create(:course) }

describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:lesson) }
end

describe 'validations' do
it 'validates uniqueness of lesson scoped to user' do
pending 'type error, course not getting attached to lesson'
expect(bookmark)
.to validate_uniqueness_of(:lesson)
.scoped_to(:user)
.with_message('user has already bookmarked this lesson')
end
end
end
1 change: 1 addition & 0 deletions spec/models/lesson_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
it { is_expected.to have_many(:project_submissions) }
it { is_expected.to have_many(:lesson_completions) }
it { is_expected.to have_many(:completing_users).through(:lesson_completions) }
it { is_expected.to have_many(:bookmarks).dependent(:destroy) }

it { is_expected.to validate_presence_of(:position) }

Expand Down
2 changes: 2 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
it { is_expected.to have_many(:notifications) }
it { is_expected.to have_many(:likes).dependent(:destroy) }
it { is_expected.to belong_to(:path).optional(true) }
it { is_expected.to have_many(:bookmarks).dependent(:destroy) }
it { is_expected.to have_many(:bookmarked_lessons).through(:bookmarks).source(:lesson) }

context 'when user is created' do
let!(:default_path) { create(:path, default_path: true) }
Expand Down