From 1146121a99be497d05abf531af4cbb5c8f9f1464 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 28 Feb 2026 17:50:12 -0500 Subject: [PATCH 1/3] Implement STI with VideoLibrary base class for Tutorial/Podcast/Intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add type column to tutorials table for STI (Single Table Inheritance) - Rename Tutorial to VideoLibrary base class, keeping table_name as 'tutorials' - Create Tutorial, Podcast, Intro subclasses - Add scopes: :tutorials, :podcasts, :intros to VideoLibrary - Update tutorials controller: - Index action shows Tutorial type only - Add video_library action shows all types - Add /video_library route - Update navigation menus: - Help → "Tutorials" (Tutorial type only) → tutorials_path - Community → "Video Library" (all types) → video_library_path - Add type selector dropdown in form (admin-only) - Create video_library and video_library_lazy views - Update home video gallery controller to use VideoLibrary - Update factory with :video_library base, :tutorial/:podcast/:intro subclasses Co-Authored-By: Claude Haiku 4.5 --- .../home/video_gallery_controller.rb | 2 +- app/controllers/tutorials_controller.rb | 21 +++++++++- app/models/tutorial.rb | 24 ++++++++--- app/views/shared/_navbar_menu.html.erb | 6 +++ app/views/shared/_navbar_menu_mobile.html.erb | 6 +++ app/views/tutorials/_form.html.erb | 1 + app/views/tutorials/video_library.html.erb | 40 +++++++++++++++++++ .../tutorials/video_library_lazy.html.erb | 22 ++++++++++ config/routes.rb | 1 + ...228174902_add_type_to_tutorials_for_sti.rb | 6 +++ spec/factories/tutorials.rb | 27 ++++++++++++- 11 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 app/views/tutorials/video_library.html.erb create mode 100644 app/views/tutorials/video_library_lazy.html.erb create mode 100644 db/migrate/20260228174902_add_type_to_tutorials_for_sti.rb diff --git a/app/controllers/home/video_gallery_controller.rb b/app/controllers/home/video_gallery_controller.rb index 8357fc504..2cfaaf818 100644 --- a/app/controllers/home/video_gallery_controller.rb +++ b/app/controllers/home/video_gallery_controller.rb @@ -4,7 +4,7 @@ class VideoGalleryController < ApplicationController def index authorize! :home - base = Tutorial.where.not(youtube_url: [ nil, "" ]).order(position: :asc, created_at: :desc) + base = VideoLibrary.where.not(youtube_url: [ nil, "" ]).order(position: :asc, created_at: :desc) @tutorials = authorized_scope(base, with: HomePolicy).decorate render "home/video_gallery/index" diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb index aca8c3f7d..c85741d7a 100644 --- a/app/controllers/tutorials_controller.rb +++ b/app/controllers/tutorials_controller.rb @@ -22,6 +22,25 @@ def index end end + def video_library + authorize! + if turbo_frame_request? + per_page = params[:number_of_items_per_page].presence || 6 + base_scope = authorized_scope(VideoLibrary.all) + filtered = base_scope.search_by_params(params) + + @count_display = filtered.size == base_scope.size ? base_scope.size : "#{filtered.count}/#{base_scope.count}" + @video_library = filtered.order(:position).paginate(page: params[:page], per_page: per_page).decorate + + render :video_library_lazy + else + @sectors = Sector.published.order(:name) + @category_types = CategoryType.published.general.order(:name).decorate + + render :video_library + end + end + def show @tutorial = @tutorial.decorate authorize! @tutorial @@ -119,7 +138,7 @@ def set_tutorial # Strong parameters def tutorial_params params.require(:tutorial).permit( - :title, :body, :rhino_body, :position, :youtube_url, + :title, :body, :rhino_body, :position, :youtube_url, :type, :featured, :published, :publicly_visible, :publicly_featured, category_ids: [], sector_ids: [], diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 6d48451a6..cc8847327 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -1,4 +1,6 @@ -class Tutorial < ApplicationRecord +class VideoLibrary < ApplicationRecord + self.table_name = 'tutorials' + include Featureable, Publishable, TagFilterable, Trendable, RichTextSearchable has_rich_text :rhino_body @@ -9,9 +11,9 @@ class Tutorial < ApplicationRecord has_many :categories, through: :categorizable_items has_many :sectors, through: :sectorable_items # Asset associations - has_one :primary_asset, -> { where(type: "PrimaryAsset") }, + has_one :primary_asset, -> { where("type" => "PrimaryAsset") }, as: :owner, class_name: "PrimaryAsset", dependent: :destroy - has_many :gallery_assets, -> { where(type: "GalleryAsset") }, + has_many :gallery_assets, -> { where("type" => "GalleryAsset") }, as: :owner, class_name: "GalleryAsset", dependent: :destroy has_many :assets, as: :owner, dependent: :destroy @@ -34,11 +36,14 @@ class Tutorial < ApplicationRecord scope :body, ->(body) { where("body like ?", "%#{ body }%") } scope :title, ->(title) { where("title like ?", "%#{ title }%") } scope :tutorial_name, ->(tutorial_name) { title(tutorial_name) } + scope :tutorials, -> { where(type: 'Tutorial') } + scope :podcasts, -> { where(type: 'Podcast') } + scope :intros, -> { where(type: 'Intro') } scope :with_sector_ids, ->(sector_hash) { ids = sector_hash.values.reject(&:blank?).map(&:to_i) return all if ids.empty? joins(:sectorable_items) - .where(sectorable_items: { sectorable_type: "Tutorial", sector_id: ids }) + .where(sectorable_items: { sectorable_type: "VideoLibrary", sector_id: ids }) .distinct } @@ -46,7 +51,7 @@ class Tutorial < ApplicationRecord ids = category_hash.values.reject(&:blank?).map(&:to_i) return all if ids.empty? joins(:categorizable_items) - .where(categorizable_items: { categorizable_type: "Tutorial", category_id: ids }) + .where(categorizable_items: { categorizable_type: "VideoLibrary", category_id: ids }) .distinct } @@ -73,3 +78,12 @@ def self.search_by_params(params) resources end end + +class Tutorial < VideoLibrary +end + +class Podcast < VideoLibrary +end + +class Intro < VideoLibrary +end diff --git a/app/views/shared/_navbar_menu.html.erb b/app/views/shared/_navbar_menu.html.erb index a1721b231..6b2e1cc26 100644 --- a/app/views/shared/_navbar_menu.html.erb +++ b/app/views/shared/_navbar_menu.html.erb @@ -94,6 +94,12 @@ Organizations <% end %> <% end %> + + <%= link_to video_library_path, + class: "flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %> + + Video Library + <% end %> diff --git a/app/views/shared/_navbar_menu_mobile.html.erb b/app/views/shared/_navbar_menu_mobile.html.erb index c1cc41839..331ec669e 100644 --- a/app/views/shared/_navbar_menu_mobile.html.erb +++ b/app/views/shared/_navbar_menu_mobile.html.erb @@ -76,6 +76,12 @@ Organizations <% end %> <% end %> + + <%= link_to video_library_path, class: "flex items-center px-4 py-2 text-sm text-white + hover:text-gray-700 hover:bg-gray-100 w-full space-x-2" do %> + + Video Library + <% end %> diff --git a/app/views/tutorials/_form.html.erb b/app/views/tutorials/_form.html.erb index a2c17caa6..a96bc5d92 100644 --- a/app/views/tutorials/_form.html.erb +++ b/app/views/tutorials/_form.html.erb @@ -9,6 +9,7 @@ value: f.object.title } %>
+ <%= f.input :type, as: :select, collection: ['Tutorial', 'Podcast', 'Intro'], prompt: 'Select type' %> <%= f.input :published, as: :boolean %> <%= f.input :featured, as: :boolean %> <%= f.input :publicly_visible, as: :boolean %> diff --git a/app/views/tutorials/video_library.html.erb b/app/views/tutorials/video_library.html.erb new file mode 100644 index 000000000..6f9e8e5a0 --- /dev/null +++ b/app/views/tutorials/video_library.html.erb @@ -0,0 +1,40 @@ +<% content_for(:page_bg_class, "public") %> +<%= render "shared/public_welcome_banner" %> + +
+
+
+
+

+ Video Library +

+ +

+ Explore our collection of tutorials, podcasts, and introductory videos +

+
+ +
+ <% if allowed_to?(:new?, Tutorial) %> + <%= link_to "New #{Tutorial.model_name.human.downcase}", + new_tutorial_path, + class: "admin-only bg-blue-100 btn btn-primary-outline" %> + <% end %> +
+
+ + <%= render "search_boxes" %> + + <% result_src = video_library_path + "?" + request.query_string %> + + <%= turbo_frame_tag "video_library_results", src: result_src do %> +
+
+ <% 3.times do %> +
+ <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/tutorials/video_library_lazy.html.erb b/app/views/tutorials/video_library_lazy.html.erb new file mode 100644 index 000000000..820e3a899 --- /dev/null +++ b/app/views/tutorials/video_library_lazy.html.erb @@ -0,0 +1,22 @@ +<%= turbo_frame_tag "video_library_results" do %> +
+
+
+ <% if @video_library.any? %> + <% @video_library.each_with_index do |item, idx| %> + <%= render "tutorial", tutorial: item, show_divider: idx > 0 %> + <% end %> + + + + <% else %> +
+ No videos found matching your filters. +
+ <% end %> +
+
+
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 0c7b7680f..ea1f098f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,6 +148,7 @@ resources :stories resources :story_shares, only: [ :index, :show ] resources :tutorials + get "video_library", to: "tutorials#video_library", as: :video_library resources :user_forms resources :windows_types resources :workshop_ideas diff --git a/db/migrate/20260228174902_add_type_to_tutorials_for_sti.rb b/db/migrate/20260228174902_add_type_to_tutorials_for_sti.rb new file mode 100644 index 000000000..b96359eeb --- /dev/null +++ b/db/migrate/20260228174902_add_type_to_tutorials_for_sti.rb @@ -0,0 +1,6 @@ +class AddTypeToTutorialsForSti < ActiveRecord::Migration[8.0] + def change + add_column :tutorials, :type, :string, default: 'Tutorial', null: false + add_index :tutorials, :type + end +end diff --git a/spec/factories/tutorials.rb b/spec/factories/tutorials.rb index 87c630367..66cc35970 100644 --- a/spec/factories/tutorials.rb +++ b/spec/factories/tutorials.rb @@ -1,11 +1,12 @@ FactoryBot.define do - factory :tutorial do + factory :video_library do title { "MyString" } body { "MyText" } featured { false } published { false } position { 1 } youtube_url { "MyString" } + type { 'Tutorial' } trait :featured do featured { true } @@ -26,5 +27,29 @@ trait :publicly_featured do publicly_featured { true } end + + trait :tutorial do + type { 'Tutorial' } + end + + trait :podcast do + type { 'Podcast' } + end + + trait :intro do + type { 'Intro' } + end + end + + factory :tutorial, class: 'Tutorial', parent: :video_library do + type { 'Tutorial' } + end + + factory :podcast, class: 'Podcast', parent: :video_library do + type { 'Podcast' } + end + + factory :intro, class: 'Intro', parent: :video_library do + type { 'Intro' } end end From 73fe11e9e854c63fedb63b86033f1090e36cdfe9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 28 Feb 2026 17:54:58 -0500 Subject: [PATCH 2/3] Rename video_gallery to video_library throughout codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Home::VideoGalleryController to Home::VideoLibraryController - Rename home/video_gallery views directory to home/video_library - Update home namespace routes: video_gallery → video_library - Update controller to render home/video_library/index Co-Authored-By: Claude Haiku 4.5 --- ...ideo_gallery_controller.rb => video_library_controller.rb} | 4 ++-- .../home/{video_gallery => video_library}/index.html.erb | 0 config/routes.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename app/controllers/home/{video_gallery_controller.rb => video_library_controller.rb} (74%) rename app/views/home/{video_gallery => video_library}/index.html.erb (100%) diff --git a/app/controllers/home/video_gallery_controller.rb b/app/controllers/home/video_library_controller.rb similarity index 74% rename from app/controllers/home/video_gallery_controller.rb rename to app/controllers/home/video_library_controller.rb index 2cfaaf818..55a27ab8d 100644 --- a/app/controllers/home/video_gallery_controller.rb +++ b/app/controllers/home/video_library_controller.rb @@ -1,5 +1,5 @@ module Home - class VideoGalleryController < ApplicationController + class VideoLibraryController < ApplicationController skip_before_action :authenticate_user! def index @@ -7,7 +7,7 @@ def index base = VideoLibrary.where.not(youtube_url: [ nil, "" ]).order(position: :asc, created_at: :desc) @tutorials = authorized_scope(base, with: HomePolicy).decorate - render "home/video_gallery/index" + render "home/video_library/index" end end end diff --git a/app/views/home/video_gallery/index.html.erb b/app/views/home/video_library/index.html.erb similarity index 100% rename from app/views/home/video_gallery/index.html.erb rename to app/views/home/video_library/index.html.erb diff --git a/config/routes.rb b/config/routes.rb index ea1f098f7..52fc6609e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -177,7 +177,7 @@ resources :stories, only: :index resources :community_news, only: :index resources :events, only: :index - resources :video_gallery, only: :index + resources :video_library, only: :index end root to: "home#index" From feb2e00a2723e4588eff980b32e752212a3c5225 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 28 Feb 2026 17:56:43 -0500 Subject: [PATCH 3/3] Add comprehensive tests for STI VideoLibrary implementation - Add model specs for .tutorials, .podcasts, .intros scopes - Add request specs for tutorials#index (Tutorial type only) and tutorials#video_library (all types) - Add routing specs for /video_library route - Add home video library controller specs: - All content types displayed with youtube_url - Excludes records without youtube_url - Correct ordering by position then created_at Co-Authored-By: Claude Haiku 4.5 --- spec/models/tutorial_spec.rb | 36 ++++++++++++++++++++++ spec/requests/home/video_library_spec.rb | 39 ++++++++++++++++++++++++ spec/requests/tutorials_spec.rb | 25 +++++++++++++++ spec/routing/tutorials_routing_spec.rb | 4 +++ 4 files changed, 104 insertions(+) create mode 100644 spec/requests/home/video_library_spec.rb diff --git a/spec/models/tutorial_spec.rb b/spec/models/tutorial_spec.rb index 1872dc491..76c006406 100644 --- a/spec/models/tutorial_spec.rb +++ b/spec/models/tutorial_spec.rb @@ -40,4 +40,40 @@ expect(results).not_to include(draft_tutorial) end end + + describe '.tutorials' do + let!(:tutorial) { create(:tutorial, title: 'Tutorial 1') } + let!(:podcast) { create(:podcast, title: 'Podcast 1') } + let!(:intro) { create(:intro, title: 'Intro 1') } + + it 'returns only Tutorial type records' do + results = VideoLibrary.tutorials + expect(results).to include(tutorial) + expect(results).not_to include(podcast, intro) + end + end + + describe '.podcasts' do + let!(:tutorial) { create(:tutorial, title: 'Tutorial 1') } + let!(:podcast) { create(:podcast, title: 'Podcast 1') } + let!(:intro) { create(:intro, title: 'Intro 1') } + + it 'returns only Podcast type records' do + results = VideoLibrary.podcasts + expect(results).to include(podcast) + expect(results).not_to include(tutorial, intro) + end + end + + describe '.intros' do + let!(:tutorial) { create(:tutorial, title: 'Tutorial 1') } + let!(:podcast) { create(:podcast, title: 'Podcast 1') } + let!(:intro) { create(:intro, title: 'Intro 1') } + + it 'returns only Intro type records' do + results = VideoLibrary.intros + expect(results).to include(intro) + expect(results).not_to include(tutorial, podcast) + end + end end diff --git a/spec/requests/home/video_library_spec.rb b/spec/requests/home/video_library_spec.rb new file mode 100644 index 000000000..4e275e61b --- /dev/null +++ b/spec/requests/home/video_library_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +RSpec.describe "Home::VideoLibrary", type: :request do + describe "GET /home/video_library" do + it "renders a successful response" do + get home_video_library_url + expect(response).to be_successful + end + + it "displays all content types with youtube_url" do + tutorial = create(:tutorial, title: 'Tutorial Video', youtube_url: 'https://www.youtube.com/watch?v=1') + podcast = create(:podcast, title: 'Podcast Video', youtube_url: 'https://www.youtube.com/watch?v=2') + intro = create(:intro, title: 'Intro Video', youtube_url: 'https://www.youtube.com/watch?v=3') + get home_video_library_url + expect(response.body).to include(tutorial.title, podcast.title, intro.title) + end + + it "excludes records without youtube_url" do + with_url = create(:tutorial, title: 'With URL', youtube_url: 'https://www.youtube.com/watch?v=1') + without_url = create(:podcast, title: 'No URL', youtube_url: nil) + get home_video_library_url + expect(response.body).to include(with_url.title) + expect(response.body).not_to include(without_url.title) + end + + it "orders by position ascending, then created_at descending" do + item1 = create(:tutorial, position: 2, youtube_url: 'https://www.youtube.com/watch?v=1') + item2 = create(:podcast, position: 1, youtube_url: 'https://www.youtube.com/watch?v=2') + item3 = create(:intro, position: 1, youtube_url: 'https://www.youtube.com/watch?v=3') + get home_video_library_url + body = response.body + pos2 = body.index(item2.title) + pos3 = body.index(item3.title) + pos1 = body.index(item1.title) + expect(pos2).to be < pos3 unless pos2.nil? || pos3.nil? + expect(pos2).to be < pos1 unless pos2.nil? || pos1.nil? + end + end +end diff --git a/spec/requests/tutorials_spec.rb b/spec/requests/tutorials_spec.rb index 17391497e..fa82d0f9a 100644 --- a/spec/requests/tutorials_spec.rb +++ b/spec/requests/tutorials_spec.rb @@ -44,6 +44,31 @@ get tutorials_url expect(response).to be_successful end + + it "shows only Tutorial type records" do + tutorial = create(:tutorial, title: 'Test Tutorial') + podcast = create(:podcast, title: 'Test Podcast') + intro = create(:intro, title: 'Test Intro') + get tutorials_url + expect(response.body).to include(tutorial.title) + expect(response.body).not_to include(podcast.title, intro.title) + end + end + + describe "GET /video_library" do + it "renders a successful response" do + create(:tutorial) + get video_library_url + expect(response).to be_successful + end + + it "shows all content types (Tutorial, Podcast, Intro)" do + tutorial = create(:tutorial, title: 'Test Tutorial') + podcast = create(:podcast, title: 'Test Podcast') + intro = create(:intro, title: 'Test Intro') + get video_library_url + expect(response.body).to include(tutorial.title, podcast.title, intro.title) + end end describe "GET /show" do diff --git a/spec/routing/tutorials_routing_spec.rb b/spec/routing/tutorials_routing_spec.rb index 61624b99e..77d2b81d0 100644 --- a/spec/routing/tutorials_routing_spec.rb +++ b/spec/routing/tutorials_routing_spec.rb @@ -34,5 +34,9 @@ it "routes to #destroy" do expect(delete: "/tutorials/1").to route_to("tutorials#destroy", id: "1") end + + it "routes /video_library to tutorials#video_library" do + expect(get: "/video_library").to route_to("tutorials#video_library") + end end end