diff --git a/app/components/bookmarks/button_component.html.erb b/app/components/bookmarks/button_component.html.erb new file mode 100644 index 0000000000..771f6a398a --- /dev/null +++ b/app/components/bookmarks/button_component.html.erb @@ -0,0 +1,10 @@ +
+ <%= button_to create_or_destroy_path, + form_class: 'w-full h-full', + method: bookmarked? ? :delete : :post, + 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 %> + + <% end %> +
diff --git a/app/components/bookmarks/button_component.rb b/app/components/bookmarks/button_component.rb new file mode 100644 index 0000000000..eb6e88c375 --- /dev/null +++ b/app/components/bookmarks/button_component.rb @@ -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 diff --git a/app/controllers/lessons_controller.rb b/app/controllers/lessons_controller.rb index 79cf8a1bb1..3e08c0c009 100644 --- a/app/controllers/lessons_controller.rb +++ b/app/controllers/lessons_controller.rb @@ -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 diff --git a/app/controllers/users/bookmarks_controller.rb b/app/controllers/users/bookmarks_controller.rb new file mode 100644 index 0000000000..212184276c --- /dev/null +++ b/app/controllers/users/bookmarks_controller.rb @@ -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 diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb new file mode 100644 index 0000000000..50de6d0320 --- /dev/null +++ b/app/models/bookmark.rb @@ -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 diff --git a/app/models/lesson.rb b/app/models/lesson.rb index eab9bc26fa..b6904878c4 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -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) } diff --git a/app/models/user.rb b/app/models/user.rb index 3781bdd93f..d18b1557f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/lessons/_lesson_buttons.html.erb b/app/views/lessons/_lesson_buttons.html.erb index b9a795a9a6..572faec9eb 100644 --- a/app/views/lessons/_lesson_buttons.html.erb +++ b/app/views/lessons/_lesson_buttons.html.erb @@ -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( diff --git a/app/views/lessons/show.html.erb b/app/views/lessons/show.html.erb index b03cba947e..35c9d0f2ff 100644 --- a/app/views/lessons/show.html.erb +++ b/app/views/lessons/show.html.erb @@ -46,7 +46,7 @@

<% 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 %> diff --git a/app/views/users/bookmarks/create.turbo_stream.erb b/app/views/users/bookmarks/create.turbo_stream.erb new file mode 100644 index 0000000000..5c9ef616c1 --- /dev/null +++ b/app/views/users/bookmarks/create.turbo_stream.erb @@ -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' %> diff --git a/app/views/users/bookmarks/destroy.turbo_stream.erb b/app/views/users/bookmarks/destroy.turbo_stream.erb new file mode 100644 index 0000000000..58b883772a --- /dev/null +++ b/app/views/users/bookmarks/destroy.turbo_stream.erb @@ -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' %> diff --git a/app/views/users/bookmarks/index.html.erb b/app/views/users/bookmarks/index.html.erb new file mode 100644 index 0000000000..7dcbbbf32a --- /dev/null +++ b/app/views/users/bookmarks/index.html.erb @@ -0,0 +1,16 @@ + +
+
+

My Bookmarked Lessons

+
+
+ <% @lessons.each do|lesson| %> +
+ + <%= link_to lesson.title, lesson %> +
+ <% end %> +
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index b67b578f8d..d73a0fa708 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20231231013215_create_bookmarks.rb b/db/migrate/20231231013215_create_bookmarks.rb new file mode 100644 index 0000000000..32a56fff5f --- /dev/null +++ b/db/migrate/20231231013215_create_bookmarks.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 20fba2b04e..1d0040e839 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_08_05_091337) do +ActiveRecord::Schema[7.0].define(version: 2023_12_31_013215) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -40,6 +40,16 @@ t.index ["user_id"], name: "index_announcements_on_user_id" end + create_table "bookmarks", force: :cascade do |t| + t.bigint "lesson_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["lesson_id"], name: "index_bookmarks_on_lesson_id" + t.index ["user_id", "lesson_id"], name: "index_bookmarks_on_user_id_and_lesson_id", unique: true + t.index ["user_id"], name: "index_bookmarks_on_user_id" + end + create_table "contents", force: :cascade do |t| t.text "body", null: false t.bigint "lesson_id", null: false @@ -283,6 +293,8 @@ end add_foreign_key "announcements", "users" + add_foreign_key "bookmarks", "lessons" + add_foreign_key "bookmarks", "users" add_foreign_key "contents", "lessons" add_foreign_key "flags", "project_submissions" add_foreign_key "flags", "users", column: "flagger_id" diff --git a/spec/factories/bookmarks.rb b/spec/factories/bookmarks.rb new file mode 100644 index 0000000000..70846d9207 --- /dev/null +++ b/spec/factories/bookmarks.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :bookmark do + user + lesson + end +end diff --git a/spec/models/bookmark_spec.rb b/spec/models/bookmark_spec.rb new file mode 100644 index 0000000000..1658451b98 --- /dev/null +++ b/spec/models/bookmark_spec.rb @@ -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 diff --git a/spec/models/lesson_spec.rb b/spec/models/lesson_spec.rb index 9ecb1e0cac..e49e442c41 100644 --- a/spec/models/lesson_spec.rb +++ b/spec/models/lesson_spec.rb @@ -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) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 017851cf6f..750fd3a668 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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) }