diff --git a/Gemfile.lock b/Gemfile.lock index 7bad4c2a9..308799d93 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,8 +91,8 @@ GEM actionpack (>= 5.0) activesupport (>= 5.0) aws-eventstream (1.4.0) - aws-partitions (1.1159.0) - aws-sdk-core (3.232.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -100,11 +100,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.112.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.199.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) diff --git a/app/controllers/community_news_controller.rb b/app/controllers/community_news_controller.rb index d8e93c4fd..02a76d867 100644 --- a/app/controllers/community_news_controller.rb +++ b/app/controllers/community_news_controller.rb @@ -4,6 +4,7 @@ class CommunityNewsController < ApplicationController def index per_page = params[:number_of_items_per_page].presence || 25 unpaginated = current_user.super_user? ? CommunityNews.all : Community_news.published + unpaginated = unpaginated.search_by_params(params) @community_news_count = unpaginated.count @community_news = unpaginated.paginate(page: params[:page], per_page: per_page) end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8c93cd9fd..a1cc5e672 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -11,11 +11,11 @@ def index @resources = Resource.includes(:windows_type, :main_image, :gallery_images) .featured .published - .published_kinds .order(ordering: :asc, created_at: :desc) .decorate @stories = Story.includes(:windows_type, :main_image, :gallery_images) - .featured.published + .featured + .published .order(:title) .decorate @community_news = CommunityNews.includes(:windows_type, :main_image, :gallery_images) @@ -25,77 +25,76 @@ def index .decorate @events = Event.includes(:event_registrations, :main_image, :gallery_images) .featured - .publicly_visible + .published .order(:start_date) .decorate end def admin if current_user.super_user? - @user_content_cards = [ - { title: "Bookmarks tally", path: tally_bookmarks_path, icon: "🔖", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "Recent portal activity", path: dashboard_recent_activities_path, icon: "🧭", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "Event Registrations", path: event_registrations_path, icon: "🎟️", - bg_color: "bg-blue-100", text_color: "text-blue-800" }, - { title: "Quotes", path: quotes_path, icon: "💬", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "Story Ideas", path: story_ideas_path, icon: "✍️️", - bg_color: "bg-rose-100", text_color: "text-rose-800" }, - { title: "Workshop Variations", path: workshop_variations_path, icon: "🔀", - bg_color: "bg-purple-100", text_color: "text-purple-800" }, - { title: "Workshop Ideas", path: workshop_ideas_path, icon: "💡", - bg_color: "bg-indigo-100", text_color: "text-indigo-800" }, - - - { title: "!!!Vision Seeds", path: authenticated_root_path, icon: "🌱", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "!!!Annual Reports", path: authenticated_root_path, icon: "📊", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "Workshop Logs", path: workshop_logs_path, icon: "📝", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - ] @system_cards = [ { title: "Banners", path: banners_path, icon: "📣", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "CommunityNews", path: community_news_index_path, icon: "📣", - bg_color: "bg-orange-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "CommunityNews", path: community_news_index_path, icon: "📰", + bg_color: "bg-orange-50", hover_bg_color: "bg-orange-100", text_color: "text-gray-800" }, { title: "Events", path: events_path, icon: "📆", - bg_color: "bg-blue-50", text_color: "text-gray-800" }, + bg_color: "bg-blue-50", hover_bg_color: "bg-blue-100", text_color: "text-gray-800" }, { title: "FAQs", path: faqs_path, icon: "❔", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, - { title: "Stories", path: stories_path, icon: "🗣️", - bg_color: "bg-rose-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "Resources", path: resources_path, icon: "📚", - bg_color: "bg-violet-50", text_color: "text-gray-800" }, + bg_color: "bg-violet-50", hover_bg_color: "bg-violet-100", text_color: "text-gray-800" }, + { title: "Stories", path: stories_path, icon: "🗣️", + bg_color: "bg-rose-50", hover_bg_color: "bg-rose-100", text_color: "text-gray-800" }, + { title: "Tags matrix", path: tags_matrix_path, icon: "🏷️", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "Workshops", path: workshops_path, icon: "🎨", - bg_color: "bg-indigo-50", text_color: "text-gray-800" }, - - - + bg_color: "bg-indigo-50", hover_bg_color: "bg-indigo-100", text_color: "text-gray-800" }, { title: "Facilitators", path: facilitators_path, icon: "🧑‍🎨", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-sky-50", hover_bg_color: "bg-sky-100", text_color: "text-gray-800" }, { title: "Organizations", path: projects_path, icon: "🏫", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-emerald-50", hover_bg_color: "bg-emerald-100", text_color: "text-gray-800" }, { title: "User accounts", path: users_path, icon: "👥", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "!!!Forms", path: authenticated_root_path, icon: "📋", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + ] + + @user_content_cards = [ + { title: "Bookmarks tally", path: tally_bookmarks_path, icon: "🔖", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "Recent portal activity", path: dashboard_recent_activities_path, icon: "🧭", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "Event Registrations", path: event_registrations_path, icon: "🎟️", + bg_color: "bg-blue-100", hover_bg_color: "bg-blue-200", text_color: "text-blue-800" }, + { title: "Quotes", path: quotes_path, icon: "💬", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "!!!Vision Seeds", path: authenticated_root_path, icon: "🌱", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "Story Ideas", path: story_ideas_path, icon: "✍️️", + bg_color: "bg-rose-100", hover_bg_color: "bg-rose-200", text_color: "text-rose-800" }, + { title: "Workshop Variations", path: workshop_variations_path, icon: "🔀", + bg_color: "bg-purple-100", hover_bg_color: "bg-purple-200", text_color: "text-purple-800" }, + { title: "Workshop Ideas", path: workshop_ideas_path, icon: "💡", + bg_color: "bg-indigo-100", hover_bg_color: "bg-indigo-200", text_color: "text-indigo-800" }, + { title: "Workshop Logs", path: workshop_logs_path, icon: "📝", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, + { title: "!!!Annual Reports", path: authenticated_root_path, icon: "📊", + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, ] + @reference_cards = [ { title: "!!!Categories", path: authenticated_root_path, icon: "🗂️", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "!!!Sectors", path: authenticated_root_path, icon: "🏭", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "!!!Project Statuses", path: authenticated_root_path, icon: "🧮️", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, { title: "WindowsTypes", path: windows_types_path, icon: "🪟", - bg_color: "bg-gray-50", text_color: "text-gray-800" }, + bg_color: "bg-gray-50", hover_bg_color: "bg-gray-100", text_color: "text-gray-800" }, # { title: "FormFields", path: authenticated_root_path, icon: "✏️", # bg_color: "bg-gray-50", text_color: "text-gray-800" }, # { title: "FormAnswerOptions", path: authenticated_root_path, icon: "🗳️", diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 91db7027b..8081e27eb 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -4,6 +4,7 @@ class EventsController < ApplicationController def index unpaginated = current_user.super_user? ? Event.all : Event.published + unpaginated = unpaginated.search_by_params(params) @events = unpaginated.order(start_date: :desc) end diff --git a/app/controllers/quotes_controller.rb b/app/controllers/quotes_controller.rb index b05908946..aeb1ed003 100644 --- a/app/controllers/quotes_controller.rb +++ b/app/controllers/quotes_controller.rb @@ -3,7 +3,9 @@ class QuotesController < ApplicationController def index per_page = params[:number_of_items_per_page].presence || 25 - unpaginated = Quote.where.not(quote: [nil, ""]).order(created_at: :desc) + unpaginated = Quote.where.not(quote: [nil, ""]) + .search_by_params(params) + .order(created_at: :desc) @quotes_count = unpaginated.count @quotes = unpaginated.paginate(page: params[:page], per_page: per_page) end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index f4a539b0f..c69da4bbd 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -4,6 +4,7 @@ class StoriesController < ApplicationController def index per_page = params[:number_of_items_per_page].presence || 25 unpaginated = current_user.super_user? ? Story.all : Story.published + unpaginated = unpaginated.search_by_params(params) @stories = unpaginated.includes(:windows_type, :project, :workshop, :created_by, :updated_by) .order(created_at: :desc) .paginate(page: params[:page], per_page: per_page) diff --git a/app/controllers/taggings_controller.rb b/app/controllers/taggings_controller.rb new file mode 100644 index 000000000..726f30025 --- /dev/null +++ b/app/controllers/taggings_controller.rb @@ -0,0 +1,91 @@ +class TaggingsController < ApplicationController + def index + @sector_names = params[:sector_names].to_s + @category_names = params[:category_names].to_s + + number_of_items_per_page = params[:number_of_items_per_page].present? ? params[:number_of_items_per_page].to_i : 9 + pages = { + workshops: params[:workshops_page], + resources: params[:resources_page], + stories: params[:stories_page], + community_news: params[:community_news_page], + events: params[:events_page], + facilitators: params[:facilitators_page], + projects: params[:projects_page], + quotes: params[:quotes_page] + } + + @grouped_tagged_items = TaggingSearchService.call(sector_names: @sector_names, + category_names: @category_names, + pages: pages, + number_of_items_per_page: number_of_items_per_page) + end + + def matrix + @sectors = + Sector + .joins(:sectorable_items) + .published + .distinct + .order(:name) + + @categories = + Category + .joins(:category_type, :categorizable_items) + .published + .select("categories.*, metadata.name AS category_type_name") + .distinct + .order("category_type_name ASC, categories.name ASC") + + + + # ------------------------------------------------------------------ + # 1. Build raw counts (SOURCE OF TRUTH) + # ------------------------------------------------------------------ + @model_heatmap_stats = {} + + Tag::TAGGABLE_MODELS.each do |key, model_class| + @model_heatmap_stats[key] = { sector: {}, category: {} } + + model_class + .published + .joins(:sectors) + .group("sectors.id") + .count + .each do |sector_id, count| + @model_heatmap_stats[key][:sector][sector_id] = count + end + + model_class + .published + .joins(:categories) + .group("categories.id") + .count + .each do |category_id, count| + @model_heatmap_stats[key][:category][category_id] = count + end + end + + # ------------------------------------------------------------------ + # 2. Compute quantiles FROM counts + # ------------------------------------------------------------------ + @model_heatmap_quantiles = {} + + @model_heatmap_stats.each do |key, dimensions| + @model_heatmap_quantiles[key] = {} + + dimensions.each do |type, counts| + values = counts.values.sort + next if values.empty? + + @model_heatmap_quantiles[key][type] = { + q20: values[(values.length * 0.2).floor], + q40: values[(values.length * 0.4).floor], + q60: values[(values.length * 0.6).floor], + q80: values[(values.length * 0.8).floor] + } + end + end + end + +end \ No newline at end of file diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 000000000..c95169456 --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,23 @@ +class TagsController < ApplicationController + def index + @sectors = + Sector + .includes(:sectorable_items) + .references(:sectorable_items) + .published + .distinct + .order(:name) + + categories = + Category + .includes(:category_type, :categorizable_items) + .references(:category_type, :categorizable_items) + .published + .select("categories.*, metadata.name AS category_type_name") + .distinct + .order("category_type_name ASC, categories.name ASC") + + @categories_by_type = categories.group_by(&:category_type_name) + end + +end diff --git a/app/decorators/application_decorator.rb b/app/decorators/application_decorator.rb new file mode 100644 index 000000000..a5a88bdee --- /dev/null +++ b/app/decorators/application_decorator.rb @@ -0,0 +1,15 @@ +class ApplicationDecorator < Draper::Decorator + delegate_all + + def link_target + object.respond_to?(:website_url) && object.website_url.present? ? + object.website_url : + default_link_target + end + + private + + def default_link_target + h.polymorphic_path(object) + end +end \ No newline at end of file diff --git a/app/decorators/banner_decorator.rb b/app/decorators/banner_decorator.rb index c0d510680..49447d1f9 100644 --- a/app/decorators/banner_decorator.rb +++ b/app/decorators/banner_decorator.rb @@ -1,11 +1,10 @@ -class BannerDecorator < Draper::Decorator - delegate_all +class BannerDecorator < ApplicationDecorator def title content.truncate(50) end - def description + def detail content end diff --git a/app/decorators/bookmark_decorator.rb b/app/decorators/bookmark_decorator.rb index a9051c38a..d24e05c37 100644 --- a/app/decorators/bookmark_decorator.rb +++ b/app/decorators/bookmark_decorator.rb @@ -1,5 +1,4 @@ -class BookmarkDecorator < Draper::Decorator - delegate_all +class BookmarkDecorator < ApplicationDecorator delegate :current_page, :total_pages, :limit_value decorates_association :bookmarkable @@ -7,7 +6,7 @@ def title "Bookmark of #{bookmarkable_class_name} ##{bookmarkable.id}" end - def description + def detail "Bookmarkable: #{bookmarkable_class_name} ##{bookmarkable.id} (#{bookmarkable.title})" end diff --git a/app/decorators/category_decorator.rb b/app/decorators/category_decorator.rb index d998ace07..7e2a0116a 100644 --- a/app/decorators/category_decorator.rb +++ b/app/decorators/category_decorator.rb @@ -1,3 +1,2 @@ -class CategoryDecorator < Draper::Decorator - delegate_all +class CategoryDecorator < ApplicationDecorator end diff --git a/app/decorators/category_type_decorator.rb b/app/decorators/category_type_decorator.rb index 87ed5e534..8a4f1a082 100644 --- a/app/decorators/category_type_decorator.rb +++ b/app/decorators/category_type_decorator.rb @@ -1,10 +1,9 @@ -class CategoryTypeDecorator < Draper::Decorator - delegate_all +class CategoryTypeDecorator < ApplicationDecorator def title name.titleize end - def description + def detail end end diff --git a/app/decorators/community_news_decorator.rb b/app/decorators/community_news_decorator.rb index bb3f1d8d6..1aa7c21ae 100644 --- a/app/decorators/community_news_decorator.rb +++ b/app/decorators/community_news_decorator.rb @@ -1,12 +1,20 @@ -class CommunityNewsDecorator < Draper::Decorator - delegate_all - - def description - body +class CommunityNewsDecorator < ApplicationDecorator + def detail(length: nil) + length ? body&.truncate(length) : body end def inactive? !published? end + def main_image_url + if main_image&.file&.attached? + Rails.application.routes.url_helpers.url_for(main_image.file) + elsif gallery_images.first&.file&.attached? + Rails.application.routes.url_helpers.url_for(gallery_images.first.file) + else + ActionController::Base.helpers.asset_path("theme_default.png") + end + end + end diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index d50f70015..bd3391036 100644 --- a/app/decorators/event_decorator.rb +++ b/app/decorators/event_decorator.rb @@ -1,11 +1,14 @@ -class EventDecorator < Draper::Decorator - delegate_all +class EventDecorator < ApplicationDecorator decorates_association :bookmarkable def date start_date.strftime("%B %d, %Y") end + def detail(length: nil) + length ? description&.truncate(length) : description + end + def calendar_links start_time = object.start_date.strftime("%Y%m%dT%H%M%SZ") end_time = object.end_date.strftime("%Y%m%dT%H%M%SZ") @@ -144,7 +147,7 @@ def main_image_url elsif gallery_images.first&.file&.attached? Rails.application.routes.url_helpers.url_for(gallery_images.first.file) else - ActionController::Base.helpers.asset_path("workshop_default_hands.png") + ActionController::Base.helpers.asset_path("theme_default.png") end end diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 7574d5c84..f4ce56477 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -1,15 +1,14 @@ -class EventRegistrationDecorator < Draper::Decorator - delegate_all - +class EventRegistrationDecorator < ApplicationDecorator def title name end - def description + def detail end def main_image_url event.decorate.main_image_url end + end diff --git a/app/decorators/facilitator_decorator.rb b/app/decorators/facilitator_decorator.rb index 362bed0df..1565c106a 100644 --- a/app/decorators/facilitator_decorator.rb +++ b/app/decorators/facilitator_decorator.rb @@ -1,22 +1,34 @@ -class FacilitatorDecorator < Draper::Decorator - delegate_all +class FacilitatorDecorator < ApplicationDecorator def title "#{first_name} #{last_name}" end - def description - "Facilitator Profile for #{first_name} #{last_name}" + def detail(length: nil) + text = user.project_users.active.map{|pu| "#{pu.title.presence || pu.position}, #{pu.project.name}"}.join(", ") if user + length ? text&.truncate(length) : text end def inactive? !user ? false : user&.inactive? end + def main_image + avatar + end + def pronouns_display profile_show_pronouns ? pronouns : nil end + def main_image_url + if avatar_image&.file&.attached? + Rails.application.routes.url_helpers.url_for(avatar_image.file) + else + ActionController::Base.helpers.asset_path("missing.png") + end + end + def member_since_year member_since ? member_since.year : nil end diff --git a/app/decorators/faq_decorator.rb b/app/decorators/faq_decorator.rb index bf538becd..4edb47fbd 100644 --- a/app/decorators/faq_decorator.rb +++ b/app/decorators/faq_decorator.rb @@ -1,11 +1,10 @@ -class FaqDecorator < Draper::Decorator - delegate_all +class FaqDecorator < ApplicationDecorator def title question end - def description + def detail answer end end diff --git a/app/decorators/form_builder_decorator.rb b/app/decorators/form_builder_decorator.rb index 9a7426e25..78eed7a4d 100644 --- a/app/decorators/form_builder_decorator.rb +++ b/app/decorators/form_builder_decorator.rb @@ -1,5 +1,4 @@ -class FormBuilderDecorator < Draper::Decorator - delegate_all +class FormBuilderDecorator < ApplicationDecorator def new_report_url if workshop_but_not_family_windows? diff --git a/app/decorators/metadatum_decorator.rb b/app/decorators/metadatum_decorator.rb index 24c8b23f7..611e50257 100644 --- a/app/decorators/metadatum_decorator.rb +++ b/app/decorators/metadatum_decorator.rb @@ -1,5 +1,4 @@ -class MetadatumDecorator < Draper::Decorator - delegate_all +class MetadatumDecorator < ApplicationDecorator decorates_association :categories def display_name diff --git a/app/decorators/notification_decorator.rb b/app/decorators/notification_decorator.rb index 65ef8702c..2e2fda4c7 100644 --- a/app/decorators/notification_decorator.rb +++ b/app/decorators/notification_decorator.rb @@ -1,10 +1,9 @@ -class NotificationDecorator < Draper::Decorator - delegate_all +class NotificationDecorator < ApplicationDecorator def title "Re #{noticeable_type} ##{noticeable_id}" end - def description + def detail end end diff --git a/app/decorators/project_decorator.rb b/app/decorators/project_decorator.rb index e6fdb4dc9..bc46b70d1 100644 --- a/app/decorators/project_decorator.rb +++ b/app/decorators/project_decorator.rb @@ -1,5 +1,16 @@ -class ProjectDecorator < Draper::Decorator - delegate_all +class ProjectDecorator < ApplicationDecorator + + def detail(length: nil) + length ? description&.truncate(length) : description + end + + def main_image_url + if logo_image&.file&.attached? + Rails.application.routes.url_helpers.url_for(logo_image.file) + else + ActionController::Base.helpers.asset_path("theme_default.png") + end + end def title name diff --git a/app/decorators/project_user_decorator.rb b/app/decorators/project_user_decorator.rb index adf9f7614..fb20c8a1f 100644 --- a/app/decorators/project_user_decorator.rb +++ b/app/decorators/project_user_decorator.rb @@ -1,8 +1,6 @@ -class ProjectUserDecorator < Draper::Decorator - delegate_all +class ProjectUserDecorator < ApplicationDecorator - - def description + def detail "#{user.full_name}: #{title.presence || position} - #{project.name}" end end diff --git a/app/decorators/quote_decorator.rb b/app/decorators/quote_decorator.rb index 25ccc8837..ed28ef12a 100644 --- a/app/decorators/quote_decorator.rb +++ b/app/decorators/quote_decorator.rb @@ -1,5 +1,4 @@ -class QuoteDecorator < Draper::Decorator - delegate_all +class QuoteDecorator < ApplicationDecorator def created_by # TODO - add to model and quote creation object.quotable_item_quotes.last&.quotable&.decorate&.created_by @@ -23,11 +22,22 @@ def attribution end end + def main_image_url + if main_image&.file&.attached? + Rails.application.routes.url_helpers.url_for(main_image.file) + elsif gallery_images.first&.file&.attached? + Rails.application.routes.url_helpers.url_for(gallery_images.first.file) + else + ActionController::Base.helpers.asset_path("theme_default.png") + end + end + def title "#{speaker_name} re #{workshop&.title}" end - def description - object.quote + def detail(length: nil) + text = object.quote + length ? text&.truncate(length) : text end end diff --git a/app/decorators/report_decorator.rb b/app/decorators/report_decorator.rb index ff0247558..38baa8a16 100644 --- a/app/decorators/report_decorator.rb +++ b/app/decorators/report_decorator.rb @@ -1,5 +1,4 @@ -class ReportDecorator < Draper::Decorator - delegate_all +class ReportDecorator < ApplicationDecorator def created_by user diff --git a/app/decorators/resource_decorator.rb b/app/decorators/resource_decorator.rb index e216d43ee..0f8a699e0 100644 --- a/app/decorators/resource_decorator.rb +++ b/app/decorators/resource_decorator.rb @@ -1,8 +1,7 @@ -class ResourceDecorator < Draper::Decorator - delegate_all +class ResourceDecorator < ApplicationDecorator - def description - text # TODO - rename field + def detail(length: nil) + length ? text&.truncate(length) : text # TODO - rename field end def featured_url diff --git a/app/decorators/sector_decorator.rb b/app/decorators/sector_decorator.rb index fc30f2b76..ecf1a9fd7 100644 --- a/app/decorators/sector_decorator.rb +++ b/app/decorators/sector_decorator.rb @@ -1,10 +1,9 @@ -class SectorDecorator < Draper::Decorator - delegate_all +class SectorDecorator < ApplicationDecorator def title name end - def description + def detail end end diff --git a/app/decorators/story_decorator.rb b/app/decorators/story_decorator.rb index 09ae6aac9..795f152e4 100644 --- a/app/decorators/story_decorator.rb +++ b/app/decorators/story_decorator.rb @@ -1,8 +1,7 @@ -class StoryDecorator < Draper::Decorator - delegate_all +class StoryDecorator < ApplicationDecorator - def description - body.truncate(50) + def detail(length: 50) + body&.truncate(length) end def inactive? diff --git a/app/decorators/story_idea_decorator.rb b/app/decorators/story_idea_decorator.rb index a91463fb2..7dcc595d2 100644 --- a/app/decorators/story_idea_decorator.rb +++ b/app/decorators/story_idea_decorator.rb @@ -1,13 +1,11 @@ -class StoryIdeaDecorator < Draper::Decorator - delegate_all - +class StoryIdeaDecorator < ApplicationDecorator def title name end - def description - body.truncate(100) + def detail(length: 100) + body&.truncate(length) end def main_image_url diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index 3d19ee4eb..40f676419 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -1,11 +1,10 @@ -class UserDecorator < Draper::Decorator - delegate_all +class UserDecorator < ApplicationDecorator def title name end - def description + def detail email end diff --git a/app/decorators/windows_type_decorator.rb b/app/decorators/windows_type_decorator.rb index 1849b13ac..9169c2056 100644 --- a/app/decorators/windows_type_decorator.rb +++ b/app/decorators/windows_type_decorator.rb @@ -1,11 +1,10 @@ -class WindowsTypeDecorator < Draper::Decorator - delegate_all +class WindowsTypeDecorator < ApplicationDecorator def title name end - def description + def detail short_name end end diff --git a/app/decorators/workshop_decorator.rb b/app/decorators/workshop_decorator.rb index cfba5c735..58b4e6ef5 100644 --- a/app/decorators/workshop_decorator.rb +++ b/app/decorators/workshop_decorator.rb @@ -1,11 +1,14 @@ # coding: utf-8 -class WorkshopDecorator < Draper::Decorator - delegate_all +class WorkshopDecorator < ApplicationDecorator def created_by user end + def detail(length: nil) + length ? description&.truncate(length) : description + end + def disable_title_field? !id.nil? end diff --git a/app/decorators/workshop_idea_decorator.rb b/app/decorators/workshop_idea_decorator.rb index 4f230fbf7..f195d36bb 100644 --- a/app/decorators/workshop_idea_decorator.rb +++ b/app/decorators/workshop_idea_decorator.rb @@ -1,5 +1,4 @@ -class WorkshopIdeaDecorator < Draper::Decorator - delegate_all +class WorkshopIdeaDecorator < ApplicationDecorator def main_image_url if main_image&.file&.attached? diff --git a/app/decorators/workshop_log_decorator.rb b/app/decorators/workshop_log_decorator.rb index 5c9c5d488..582a28270 100644 --- a/app/decorators/workshop_log_decorator.rb +++ b/app/decorators/workshop_log_decorator.rb @@ -1,5 +1,4 @@ -class WorkshopLogDecorator < Draper::Decorator - delegate_all +class WorkshopLogDecorator < ApplicationDecorator def main_image_url if main_image&.file&.attached? diff --git a/app/decorators/workshop_variation_decorator.rb b/app/decorators/workshop_variation_decorator.rb index 9862ca8a1..308631bd5 100644 --- a/app/decorators/workshop_variation_decorator.rb +++ b/app/decorators/workshop_variation_decorator.rb @@ -1,5 +1,4 @@ -class WorkshopVariationDecorator < Draper::Decorator - delegate_all +class WorkshopVariationDecorator < ApplicationDecorator def breadcrumbs "#{workshop_link} >> #{name}".html_safe diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 00ad437c9..c64b49946 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -1,7 +1,11 @@ module PaginationHelper def tailwind_paginate(collection, options = {}) + raw = collection.respond_to?(:object) ? collection.object : collection + + return nil unless raw.respond_to?(:total_pages) + will_paginate( - collection, + raw, { renderer: TailwindPaginationRenderer, inner_window: 2, @@ -11,4 +15,3 @@ def tailwind_paginate(collection, options = {}) ) end end - diff --git a/app/helpers/tag_matrix_helper.rb b/app/helpers/tag_matrix_helper.rb new file mode 100644 index 000000000..8d009f3af --- /dev/null +++ b/app/helpers/tag_matrix_helper.rb @@ -0,0 +1,90 @@ +module TagMatrixHelper + + def tag_count_for(model, tag:, type:) + scope = + case type + when :sector + model.sector_names(tag.name) + when :category + model.category_names(tag.name) + end + + scope.published.count + end + + def tag_link_for(model, tag:, type:) + params = + case type + when :sector + { sector_names: tag.name, published: true } + when :category + { category_names: tag.name, published: true } + end + + polymorphic_path(model, params) + end + + def heatmap_class_for(count, model_key:, type:, quantiles:) + return "bg-white" if count.zero? + + q = quantiles[model_key][type] + return base_color(model_key, 200) if q.nil? + + shade = + if count <= q[:q20] + 50 + elsif count <= q[:q40] + 100 + elsif count <= q[:q60] + 200 + elsif count <= q[:q80] + 300 + else + 400 + end + + base_color(model_key, shade) + end + + def base_color(model_key, shade) + color = Tag::TAGGABLE_COLORS.fetch(model_key, :gray) + { + indigo: { + 50 => "bg-indigo-50", 100 => "bg-indigo-100", + 200 => "bg-indigo-200", 300 => "bg-indigo-300", 400 => "bg-indigo-400" + }, + violet: { + 50 => "bg-violet-50", 100 => "bg-violet-100", + 200 => "bg-violet-200", 300 => "bg-violet-300", 400 => "bg-violet-400" + }, + orange: { + 50 => "bg-orange-50", 100 => "bg-orange-100", + 200 => "bg-orange-200", 300 => "bg-orange-300", 400 => "bg-orange-400" + }, + rose: { + 50 => "bg-rose-50", 100 => "bg-rose-100", + 200 => "bg-rose-200", 300 => "bg-rose-300", 400 => "bg-rose-400" + }, + blue: { + 50 => "bg-blue-50", 100 => "bg-blue-100", + 200 => "bg-blue-200", 300 => "bg-blue-300", 400 => "bg-blue-400" + }, + sky: { + 50 => "bg-sky-50", 100 => "bg-sky-100", + 200 => "bg-sky-200", 300 => "bg-sky-300", 400 => "bg-sky-400" + }, + cyan: { + 50 => "bg-cyan-50", 100 => "bg-cyan-100", + 200 => "bg-cyan-200", 300 => "bg-cyan-300", 400 => "bg-cyan-400" + }, + emerald: { + 50 => "bg-emerald-50", 100 => "bg-emerald-100", + 200 => "bg-emerald-200", 300 => "bg-emerald-300", 400 => "bg-emerald-400" + }, + gray: { + 50 => "bg-gray-50", 100 => "bg-gray-100", + 200 => "bg-gray-200", 300 => "bg-gray-300", 400 => "bg-gray-400" + } + }.dig(color, shade) || "bg-gray-100" + end +end diff --git a/app/helpers/taggings_helper.rb b/app/helpers/taggings_helper.rb new file mode 100644 index 000000000..4a9e9f8cf --- /dev/null +++ b/app/helpers/taggings_helper.rb @@ -0,0 +1,26 @@ +module TaggingsHelper + + def tagged_index_path(type, sector_names:, category_names:) + model = Tag::TAGGABLE_MODELS.fetch(type) + + params = {} + + if sector_names.present? + sector_ids = Sector.names(sector_names).published.pluck(:id) + params[:sectors] = hashify_ids(sector_ids) + end + + if category_names.present? + category_ids = Category.names(category_names).published.pluck(:id) + params[:categories] = hashify_ids(category_ids) + end + + polymorphic_path(model, params) + end + + private + + def hashify_ids(ids) + ids.index_with(&:to_i) + end +end diff --git a/app/models/category.rb b/app/models/category.rb index 972f68d8c..63ad7ae15 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,4 +1,6 @@ class Category < ApplicationRecord + include NameFilterable + belongs_to :category_type, class_name: "CategoryType", foreign_key: :metadatum_id has_many :categorizable_items, dependent: :destroy has_many :workshops, through: :categorizable_items, source: :categorizable, source_type: 'Workshop' diff --git a/app/models/community_news.rb b/app/models/community_news.rb index a803d0286..4b72411db 100644 --- a/app/models/community_news.rb +++ b/app/models/community_news.rb @@ -1,15 +1,22 @@ class CommunityNews < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :project, optional: true belongs_to :windows_type, optional: true belongs_to :author, class_name: "User", optional: true belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable + has_many :sectorable_items, dependent: :destroy, inverse_of: :sectorable, as: :sectorable # Image associations has_one :main_image, -> { where(type: "Images::MainImage") }, as: :owner, class_name: "Images::MainImage", dependent: :destroy has_many :gallery_images, -> { where(type: "Images::GalleryImage") }, as: :owner, class_name: "Images::GalleryImage", dependent: :destroy + # has_many through + has_many :categories, through: :categorizable_items + has_many :sectors, through: :sectorable_items # Validations validates :author_id, presence: true @@ -20,6 +27,24 @@ class CommunityNews < ApplicationRecord accepts_nested_attributes_for :main_image, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :gallery_images, allow_destroy: true, reject_if: :all_blank + # SearchCop + include SearchCop + search_scope :search do + attributes :title, :body + end + scope :featured, -> { where(featured: true) } - scope :published, -> { where(published: true) } + scope :published, ->(published=nil) { published ? where(published: published) : where(published: true) } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } + + def self.search_by_params(params) + community_news = self.all + community_news = community_news.search(params[:query]) if params[:query].present? + community_news = community_news.sector_names(params[:sector_names]) if params[:sector_names].present? + community_news = community_news.category_names(params[:category_names]) if params[:category_names].present? + community_news = community_news.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + community_news = community_news.published(params[:published]) if params[:published].present? + community_news + end end diff --git a/app/models/concerns/linkable.rb b/app/models/concerns/linkable.rb new file mode 100644 index 000000000..7664a0d2a --- /dev/null +++ b/app/models/concerns/linkable.rb @@ -0,0 +1,13 @@ +module Linkable + extend ActiveSupport::Concern + + def link_target + website_url.presence || default_link_target + end + + private + + def default_link_target + Rails.application.routes.url_helpers.polymorphic_path(self) + end +end diff --git a/app/models/concerns/name_filterable.rb b/app/models/concerns/name_filterable.rb new file mode 100644 index 000000000..e4bc0c760 --- /dev/null +++ b/app/models/concerns/name_filterable.rb @@ -0,0 +1,23 @@ +module NameFilterable + extend ActiveSupport::Concern + + class_methods do + def names(input) + return none if input.blank? + + parsed = + Array(input) + .flat_map { |v| v.to_s.split("--") } + .map(&:strip) + .reject(&:blank?) + .map(&:downcase) + + return none if parsed.empty? + + conditions = parsed.map { "LOWER(name) LIKE ?" }.join(" OR ") + values = parsed.map { |v| "%#{v}%" } + + where(conditions, *values) + end + end +end diff --git a/app/models/concerns/tag_filterable.rb b/app/models/concerns/tag_filterable.rb new file mode 100644 index 000000000..5ba910377 --- /dev/null +++ b/app/models/concerns/tag_filterable.rb @@ -0,0 +1,26 @@ +# app/models/concerns/tag_filterable.rb +module TagFilterable + extend ActiveSupport::Concern + + class_methods do + def tag_names(association, names) + return all if names.blank? + + parsed_names = + Array(names) + .flat_map { |n| n.to_s.split("--") } + .map(&:strip) + .reject(&:blank?) + .map(&:downcase) + + return all if parsed_names.empty? + + reflection = reflect_on_association(association) + table_name = reflection.klass.table_name + + joins(association) + .where("LOWER(#{table_name}.name) IN (?)", parsed_names) + .distinct + end + end +end diff --git a/app/models/concerns/windows_type_filterable.rb b/app/models/concerns/windows_type_filterable.rb new file mode 100644 index 000000000..711730873 --- /dev/null +++ b/app/models/concerns/windows_type_filterable.rb @@ -0,0 +1,28 @@ +module WindowsTypeFilterable + extend ActiveSupport::Concern + + included do + scope :windows_type_name, ->(windows_type_name) do + return all if windows_type_name.blank? + + normalized = normalize_windows_type_name(windows_type_name) + + joins(:windows_type) + .where("LOWER(windows_types.short_name) LIKE ?", "%#{normalized}%") + end + end + + class_methods do + def normalize_windows_type_name(value) + value = value.to_s.downcase + + if value.include?("family") || value.include?("combined") + "combined" + elsif value.include?("child") + "children" + else + "adult" + end + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb index ef3c3fcb4..b80cff83f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,13 +1,21 @@ class Event < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :created_by, class_name: "User", optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :event_registrations, dependent: :destroy - has_many :registrants, through: :event_registrations, class_name: "User" + + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable + has_many :sectorable_items, as: :sectorable, dependent: :destroy # Image associations has_one :main_image, -> { where(type: "Images::MainImage") }, as: :owner, class_name: "Images::MainImage", dependent: :destroy has_many :gallery_images, -> { where(type: "Images::GalleryImage") }, as: :owner, class_name: "Images::GalleryImage", dependent: :destroy + # has_many through + has_many :registrants, through: :event_registrations, class_name: "User" + has_many :categories, through: :categorizable_items + has_many :sectors, through: :sectorable_items # Validations validates_presence_of :title, :start_date, :end_date @@ -17,9 +25,26 @@ class Event < ApplicationRecord accepts_nested_attributes_for :main_image, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :gallery_images, allow_destroy: true, reject_if: :all_blank + # SearchCop + include SearchCop + search_scope :search do + attributes :title, :description + end + scope :featured, -> { where(featured: true) } - scope :published, -> { publicly_visible } - scope :publicly_visible, -> { where(publicly_visible: true) } + scope :published, ->(published=nil) { publicly_visible(published) } + scope :publicly_visible, ->(publicly_visible=nil) { publicly_visible ? where(publicly_visible: publicly_visible): where(publicly_visible: true) } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } + + def self.search_by_params(params) + stories = self.all + stories = stories.search(params[:query]) if params[:query].present? + stories = stories.sector_names(params[:sector_names]) if params[:sector_names].present? + stories = stories.category_names(params[:category_names]) if params[:category_names].present? + stories = stories.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + stories + end def inactive? !publicly_visible diff --git a/app/models/facilitator.rb b/app/models/facilitator.rb index 392958a91..c2b6e97aa 100644 --- a/app/models/facilitator.rb +++ b/app/models/facilitator.rb @@ -1,14 +1,19 @@ class Facilitator < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" has_one :user, inverse_of: :facilitator, dependent: :nullify has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable has_many :sectorable_items, as: :sectorable, dependent: :destroy - has_many :sectors, through: :sectorable_items has_many :stories_as_spotlighted_facilitator, inverse_of: :spotlighted_facilitator, class_name: "Story", dependent: :restrict_with_error # has_many through has_many :event_registrations, through: :user + has_many :categories, through: :categorizable_items + has_many :sectors, through: :sectorable_items + # Image associations has_one :avatar_image, -> { where(type: "Images::SquareImage") }, @@ -43,25 +48,25 @@ class Facilitator < ApplicationRecord attributes user_phone: "user.phone" end - scope :searchable, -> { where(profile_is_searchable: true) } + scope :active, -> { all } # TODO - implement inactive field + scope :published, -> { active.searchable } + scope :published, ->(published=nil) { published ? active.searchable(published) : active.searchable } + scope :searchable, ->(searchable=nil) { searchable ? where(profile_is_searchable: searchable) : where(profile_is_searchable: true) } scope :project_name, ->(project_name) { return all if project_name.blank? left_joins(user: { project_users: :project }) .where("projects.name LIKE ?", "%#{sanitize_sql_like(project_name)}%") - .distinct - } - scope :sector_name, ->(sector_name) { - return all if sector_name.blank? - left_joins(sectorable_items: :sector) - .where("sectors.name LIKE ?", "%#{sanitize_sql_like(sector_name)}%") - .distinct - } + .distinct } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } def self.search_by_params(params) results = self.all results = results.search(params[:contact_info]) if params[:contact_info].present? - results = results.sector_name(params[:sector_name]) if params[:sector_name].present? + results = results.sector_names(params[:sector_names]) if params[:sector_names].present? + results = results.sector_names(params[:category_names]) if params[:category_names].present? results = results.project_name(params[:project_name]) if params[:project_name].present? + results = results.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? results end diff --git a/app/models/project.rb b/app/models/project.rb index eb8dcbbab..526632359 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1,4 +1,6 @@ class Project < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :project_status belongs_to :project_obligation, optional: true belongs_to :location, optional: true # TODO - remove Location if unused @@ -9,8 +11,13 @@ class Project < ApplicationRecord has_many :users, through: :project_users has_many :reports, through: :users has_many :workshop_logs, through: :users + + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable has_many :sectorable_items, as: :sectorable, dependent: :destroy + # has_many through + has_many :categories, through: :categorizable_items has_many :sectors, through: :sectorable_items + # Image associations has_one :logo_image, -> { where(type: "Images::SquareImage") }, as: :owner, class_name: "Images::SquareImage", dependent: :destroy @@ -25,8 +32,6 @@ class Project < ApplicationRecord accepts_nested_attributes_for :sectorable_items, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :project_users, allow_destroy: true, reject_if: :all_blank - scope :active, -> { where(inactive: false) } - # SearchCop include SearchCop search_scope :search do @@ -53,19 +58,16 @@ class Project < ApplicationRecord SQL wildcard: wildcard, exact: exact) end - scope :windows_type_name, ->(windows_type_name) do - return all if windows_type_name.blank? - if windows_type_name.downcase.include?("adult") - windows_type_name = "ADULT WORKSHOP" - elsif windows_type_name.downcase.include?("child") - windows_type_name = "CHILDREN WORKSHOP" - end - joins(:windows_type).where("windows_types.name LIKE ?", "%#{ windows_type_name }%") - end + scope :active, ->(active=nil) { active ? where(inactive: !active) : where(inactive: false) } + scope :published, ->(published=nil) { published ? active(published) : active } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } def self.search_by_params(params) - projects = Project.all + projects = self.all projects = projects.search(params[:query]) if params[:query].present? + projects = projects.sector_names(params[:sector_names]) if params[:sector_names].present? + projects = projects.category_names(params[:category_names]) if params[:category_names].present? projects = projects.address(params[:address]) if params[:address].present? projects = projects.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? projects diff --git a/app/models/quote.rb b/app/models/quote.rb index 6de7196d8..0e1f22321 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -1,10 +1,43 @@ class Quote < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :workshop, optional: true + has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :quotable_item_quotes, dependent: :destroy + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable + has_many :sectorable_items, dependent: :destroy, inverse_of: :sectorable, as: :sectorable + # Image associations + has_one :main_image, -> { where(type: "Images::MainImage") }, + as: :owner, class_name: "Images::MainImage", dependent: :destroy + has_many :gallery_images, -> { where(type: "Images::GalleryImage") }, + as: :owner, class_name: "Images::GalleryImage", dependent: :destroy + # has_many through + has_many :categories, through: :categorizable_items + has_many :sectors, through: :sectorable_items validates :quote, presence: true, unless: -> { quote.blank? } - scope :active, -> { where(inactive: false) } + # Search Cop + include SearchCop + search_scope :search do + attributes :quote + end + + scope :active, ->(active=nil) { active ? where(inactive: !active) : where(inactive: false) } + scope :published, ->(published=nil) { published ? active(published) : active } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } + + def self.search_by_params(params) + quotes = all + quotes = quotes.search(params[:query]) if params[:query].present? # SearchCop incl title, author, text + quotes = quotes.sector_names(params[:sector_names]) if params[:sector_names].present? + quotes = quotes.category_names(params[:category_names]) if params[:category_names].present? + quotes = quotes.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + quotes = quotes.published(params[:published]) if params[:published].present? + quotes = quotes.featured(params[:featured]) if params[:featured].present? + quotes + end def speaker speaker_name.nil? || speaker_name.empty? ? "Participant" : speaker_name diff --git a/app/models/resource.rb b/app/models/resource.rb index 886d77bbf..6bb32a5b7 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -1,4 +1,5 @@ class Resource < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable include Rails.application.routes.url_helpers PUBLISHED_KINDS = ["Handout", "Scholarship", "Template", "Toolkit", "Form"] @@ -55,12 +56,14 @@ class Resource < ApplicationRecord # Scopes scope :by_created, -> { order(created_at: :desc) } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } scope :featured, -> (featured=nil) { featured.present? ? where(featured: featured) : where(featured: true) } scope :kind, -> (kind) { where("kind like ?", kind ) } scope :leader_spotlights, -> { kind("LeaderSpotlight") } scope :published_kinds, -> { where(kind: PUBLISHED_KINDS) } scope :published, -> (published=nil) { published.present? ? - where(inactive: !published) : where(inactive: false) } + where(inactive: !published).published_kinds : where(inactive: false).published_kinds } scope :recent, -> { published.by_created } scope :sector_impact, -> { where(kind: "SectorImpact") } scope :scholarship, -> { where(kind: "Scholarship") } @@ -68,6 +71,19 @@ class Resource < ApplicationRecord scope :theme, -> { where(kind: "Theme") } scope :title, -> (title) { where("title like ?", "%#{ title }%") } + def self.search_by_params(params) + resources = all + resources = resources.search(params[:query]) if params[:query].present? # SearchCop incl title, author, text + resources = resources.sector_names(params[:sector_names]) if params[:sector_names].present? + resources = resources.category_names(params[:category_names]) if params[:category_names].present? + resources = resources.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + resources = resources.title(params[:title]) if params[:title].present? + resources = resources.kind(params[:kind]) if params[:kind].present? + resources = resources.published(params[:published]) if params[:published].present? + resources = resources.featured(params[:featured]) if params[:featured].present? + resources + end + def story? ["Story", "LeaderSpotlight"].include? self.kind end @@ -108,16 +124,6 @@ def month created_at.month end - def self.search_by_params(params) - resources = all - resources = resources.search(params[:query]) if params[:query].present? # SearchCop incl title, author, text - resources = resources.title(params[:title]) if params[:title].present? - resources = resources.kind(params[:kind]) if params[:kind].present? - resources = resources.published(params[:published]) if params[:published].present? - resources = resources.featured(params[:featured]) if params[:featured].present? - resources - end - private def self.reject?(resource) resource['_create'] == '0' diff --git a/app/models/sector.rb b/app/models/sector.rb index dac6e4049..9a4b783a5 100644 --- a/app/models/sector.rb +++ b/app/models/sector.rb @@ -1,6 +1,6 @@ class Sector < ApplicationRecord attr_accessor :_create - + include NameFilterable SECTOR_TYPES = ['Veterans & Military', 'Sexual Assault', 'Substance Abuse', 'LGBTQIA', 'Child Abuse', 'Education/Schools', 'Domestic Violence', 'Other' ] @@ -16,4 +16,5 @@ class Sector < ApplicationRecord # Scopes scope :published, -> { where(published: true). order(Arel.sql("CASE WHEN name = 'Other' THEN 1 ELSE 0 END, LOWER(name) ASC")) } + scope :has_taggings, -> { joins(:sectorable_items).distinct } end diff --git a/app/models/story.rb b/app/models/story.rb index 31b9b519d..016d36a77 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -1,4 +1,6 @@ class Story < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable + belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :windows_type @@ -8,11 +10,16 @@ class Story < ApplicationRecord belongs_to :story_idea, optional: true belongs_to :workshop, optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable + has_many :sectorable_items, dependent: :destroy, inverse_of: :sectorable, as: :sectorable # Image associations has_one :main_image, -> { where(type: "Images::MainImage") }, as: :owner, class_name: "Images::MainImage", dependent: :destroy has_many :gallery_images, -> { where(type: "Images::GalleryImage") }, as: :owner, class_name: "Images::GalleryImage", dependent: :destroy + # has_many through + has_many :categories, through: :categorizable_items + has_many :sectors, through: :sectorable_items # Validations validates :windows_type_id, presence: true @@ -24,9 +31,26 @@ class Story < ApplicationRecord accepts_nested_attributes_for :main_image, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :gallery_images, allow_destroy: true, reject_if: :all_blank + # SearchCop + include SearchCop + search_scope :search do + attributes :title, :body + end + # Scopes scope :featured, -> { where(featured: true) } - scope :published, -> { where(published: true) } + scope :published, ->(published=nil) { published ? where(published: published) : where(published: true) } + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } + + def self.search_by_params(params) + stories = self.all + stories = stories.search(params[:query]) if params[:query].present? + stories = stories.sector_names(params[:sector_names]) if params[:sector_names].present? + stories = stories.category_names(params[:category_names]) if params[:category_names].present? + stories = stories.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + stories + end def name title diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 000000000..ff97f053e --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,40 @@ +class Tag + TAGGABLE_MODELS = { + workshops: Workshop, + resources: Resource, + community_news: CommunityNews, + stories: Story, + events: Event, + facilitators: Facilitator, + projects: ::Project, + quotes: Quote + } + + TAGGABLE_COLORS = { + workshops: :indigo, + workshops_variations: :purple, + workshop_logs: :teal, + resources: :violet, + community_news: :orange, + stories: :rose, + events: :blue, + facilitators: :sky, + projects: :emerald, + quotes: :slate, + tags: :lime + } + + def self.color_for(key) + TAGGABLE_COLORS[key.to_sym] + end + + def self.bg_class_for(key, intensity: 50) + color = color_for(key) + color ? "bg-#{color}-#{intensity}" : "bg-gray-50" + end + + def self.text_class_for(key, intensity: 700) + color = color_for(key) + color ? "text-#{color}-#{intensity}" : "text-gray-700" + end +end \ No newline at end of file diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 30d96da16..a4d616c57 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -1,4 +1,5 @@ class Workshop < ApplicationRecord + include Linkable, TagFilterable, WindowsTypeFilterable include Rails.application.routes.url_helpers belongs_to :windows_type @@ -66,13 +67,26 @@ class Workshop < ApplicationRecord # Scopes + scope :category_names, ->(names) { tag_names(:categories, names) } + scope :sector_names, ->(names) { tag_names(:sectors, names) } scope :created_by_id, ->(created_by_id) { where(user_id: created_by_id) } scope :featured, -> { where(featured: true) } scope :legacy, -> { where(legacy: true) } scope :published, -> (published=nil) { published.to_s.present? ? - where(inactive: !published) : where(inactive: false) } + where(inactive: !published) : where(inactive: false) } scope :title, -> (title) { where("workshops.title like ?", "%#{ title }%") } scope :windows_type_ids, ->(windows_type_ids) { where(windows_type_id: windows_type_ids) } + scope :order_by_date, ->(sort_order="asc") { + order(Arel.sql(<<~SQL.squish)) + COALESCE( + STR_TO_DATE( + CONCAT(workshops.year, '-', LPAD(workshops.month, 2, '0'), '-01'), + '%Y-%m-%d' + ), + DATE(workshops.created_at) + ) #{sort_order == "asc" ? "ASC" : "DESC"} + SQL + } # Search Cop include SearchCop diff --git a/app/models/workshop_series_membership.rb b/app/models/workshop_series_membership.rb index a9a5781f1..7c521bc75 100644 --- a/app/models/workshop_series_membership.rb +++ b/app/models/workshop_series_membership.rb @@ -14,7 +14,7 @@ def series_description_for(spanish: false, length: nil, parent_workshop: false) end if description.present? - length ? description.truncate(length) : description + length ? description&.truncate(length) : description else workshop = parent_workshop ? workshop_parent : workshop_child workshop.decorate.formatted_objective(length: length) diff --git a/app/services/tagging_search_service.rb b/app/services/tagging_search_service.rb new file mode 100644 index 000000000..59435a6b9 --- /dev/null +++ b/app/services/tagging_search_service.rb @@ -0,0 +1,89 @@ +class TaggingSearchService + def self.call(sector_names:, category_names: nil, + pages: {}, number_of_items_per_page: nil) + if sector_names.blank? && category_names.blank? + return empty_results(number_of_items_per_page) + end + + { + workshops: Workshop + .includes(:sectors, :categories, :windows_type, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order_by_date("desc") + .paginate(page: pages[:workshops] || 1, per_page: number_of_items_per_page) + .decorate, + + resources: Resource + .includes(:windows_type, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(:title) + .paginate(page: pages[:resources] || 1, per_page: number_of_items_per_page) + .decorate, + + community_news: CommunityNews + .includes(:windows_type, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(updated_at: :desc) + .paginate(page: pages[:community_news] || 1, per_page: number_of_items_per_page) + .decorate, + + events: Event + .includes(:event_registrations, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(:start_date) + .paginate(page: pages[:events] || 1, per_page: number_of_items_per_page) + .decorate, + + stories: Story + .includes(:windows_type, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(updated_at: :desc) + .paginate(page: pages[:stories] || 1, per_page: number_of_items_per_page) + .decorate, + + facilitators: Facilitator + .includes(:sectors, :avatar_image) + .published + .searchable + .sector_names(sector_names) + .category_names(category_names) + .order(:first_name, :last_name) + .paginate(page: pages[:facilitators] || 1, per_page: number_of_items_per_page) + .decorate, + + projects: Project + .includes(:sectors, :logo_image) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(:name) + .paginate(page: pages[:projects] || 1, per_page: number_of_items_per_page) + .decorate, + + quotes: Quote + .includes(:sectors, :main_image, :gallery_images) + .published + .sector_names(sector_names) + .category_names(category_names) + .order(:quote) + .paginate(page: pages[:quotes] || 1, per_page: number_of_items_per_page) + .decorate, + } + end + + def self.empty_results(per_page) + Tag::TAGGABLE_MODELS.keys.index_with do + WillPaginate::Collection.create(1, per_page || 9, 0) { |pager| pager.replace([]) } + end + end +end diff --git a/app/services/workshop_search_service.rb b/app/services/workshop_search_service.rb index edd44f0f0..a0712fc71 100644 --- a/app/services/workshop_search_service.rb +++ b/app/services/workshop_search_service.rb @@ -11,6 +11,7 @@ def initialize(params = {}, super_user: false) # Main entry point def call + normalize_published_param filter_by_params order_by_params resolve_ids_order @@ -31,6 +32,7 @@ def default_sort def filter_by_params filter_by_windows_type + filter_by_windows_type_name filter_by_published_status filter_by_title filter_by_query @@ -45,6 +47,11 @@ def filter_by_windows_type @workshops = @workshops.windows_type_ids(ids) end + def filter_by_windows_type_name + return unless params[:windows_type_name].present? + @workshops = @workshops.windows_type_name(params[:windows_type_name]) + end + def filter_by_published_status if super_user active = ActiveModel::Type::Boolean.new.cast(params[:active]) if params.key?(:active) @@ -74,8 +81,31 @@ def filter_by_categories end def filter_by_sectors - return unless params[:sectors].present? - @workshops = search_by_sectors(@workshops, params[:sectors]) + sector_ids = [] + + # From dropdown IDs + if params[:sectors].present? + sector_ids += params[:sectors].to_unsafe_h.values.reject(&:blank?).map(&:to_i) + end + + # From tagging links (?sector_names=...) + sector_ids += sector_ids_from_names + + sector_ids.uniq! + return if sector_ids.blank? + + @workshops = @workshops + .joins(:sectorable_items) + .where( + sectorable_items: { + sectorable_type: "Workshop", + sector_id: sector_ids + } + ) + .distinct + + params[:sectors] ||= {} + sector_ids.each { |id| params[:sectors][id.to_s] = id.to_s } end def filter_by_title @@ -131,6 +161,37 @@ def search_by_sectors(workshops, sectors) .distinct end + def sector_ids_from_names + return [] if params[:sector_names].blank? + + names = + params[:sector_names] + .to_s + .split("--") + .map(&:strip) + .reject(&:blank?) + + return [] if names.empty? + + Sector + .names(names) # your case-insensitive / partial matching scope + .pluck(:id) + end + + def normalize_published_param + return unless params.key?(:published) + + published = ActiveModel::Type::Boolean.new.cast(params[:published]) + + if published + params[:active] = true + params.delete(:inactive) + else + params[:inactive] = true + params.delete(:active) + end + end + # --- Sorting --- def order_by_params diff --git a/app/views/categories/_tagging_label.html.erb b/app/views/categories/_tagging_label.html.erb new file mode 100644 index 000000000..1e284c68b --- /dev/null +++ b/app/views/categories/_tagging_label.html.erb @@ -0,0 +1,18 @@ +<% category ||= nil %> +<% name_only ||= false %> + +<% if category %> + + <%= link_to taggings_path(category_names: category.name), + class: "inline-flex items-center + rounded-md + border border-gray-300 + bg-white + text-gray-500 + px-3 py-1 + text-sm font-medium + hover:bg-gray-100 hover:border-gray-400 + transition" do %> + <%= "#{category.category_type&.name}: " if !name_only && category.category_type %><%= category.name %> + <% end %> +<% end %> diff --git a/app/views/dashboard/_community_news.html.erb b/app/views/dashboard/_community_news.html.erb index b760663b1..ac7181246 100644 --- a/app/views/dashboard/_community_news.html.erb +++ b/app/views/dashboard/_community_news.html.erb @@ -1,7 +1,9 @@ +<% title ||= "Community News" %> +
- <%= render "dashboard_section_header", - title: "Community news", + <%= render "dashboard/dashboard_section_header", + title: title, subtitle: "Announcements and updates" %> <% if community_news.any? %> @@ -12,7 +14,7 @@
- <%= link_to "View all news", + <%= link_to "Read all news", community_news_index_path, class: "text-primary font-medium hover:underline" %>
diff --git a/app/views/dashboard/_events.html.erb b/app/views/dashboard/_events.html.erb index 0c5e23a64..9005eb1dc 100644 --- a/app/views/dashboard/_events.html.erb +++ b/app/views/dashboard/_events.html.erb @@ -1,7 +1,9 @@ +<% title ||= "Upcoming Events" %> +
- <%= render "dashboard_section_header", - title: "Events", + <%= render "dashboard/dashboard_section_header", + title: title, subtitle: "Workshops, trainings, and upcoming gatherings" %> <% if events.any? %> diff --git a/app/views/dashboard/_recent_activity.html.erb b/app/views/dashboard/_recent_activity.html.erb index 6bd659f26..353cda23f 100644 --- a/app/views/dashboard/_recent_activity.html.erb +++ b/app/views/dashboard/_recent_activity.html.erb @@ -46,7 +46,7 @@ overflow-hidden flex flex-col md:flex-row">

- <%= activity.description %> + <%= activity.detail %>

diff --git a/app/views/dashboard/_resources.html.erb b/app/views/dashboard/_resources.html.erb index c416e16b9..ecfa099e6 100644 --- a/app/views/dashboard/_resources.html.erb +++ b/app/views/dashboard/_resources.html.erb @@ -1,7 +1,9 @@ +<% title ||= "Popular Resources" %> +
- <%= render "dashboard_section_header", - title: "Popular Resources", + <%= render "dashboard/dashboard_section_header", + title: title, subtitle: "Tools and activities used across our network" %> <% if @resources.any? %> diff --git a/app/views/dashboard/_stories.html.erb b/app/views/dashboard/_stories.html.erb index 2bc2ec895..10f412465 100644 --- a/app/views/dashboard/_stories.html.erb +++ b/app/views/dashboard/_stories.html.erb @@ -1,7 +1,9 @@ +<% title ||= "Highlighted Stories" %> +
- <%= render "dashboard_section_header", - title: "Stories", + <%= render "dashboard/dashboard_section_header", + title: title, subtitle: "Voices and experiences from our community" %> <% if stories.any? %> diff --git a/app/views/dashboard/_workshops.html.erb b/app/views/dashboard/_workshops.html.erb index 511961911..f66a90955 100644 --- a/app/views/dashboard/_workshops.html.erb +++ b/app/views/dashboard/_workshops.html.erb @@ -1,13 +1,16 @@ +<% title ||= "Featured Workshops" %> +
- <%= render "dashboard_section_header", title: "Featured Workshops", - subtitle: "Spotlights from our recent programs" %> + <%= render "dashboard/dashboard_section_header", + title: title, + subtitle: "Spotlights from our curriculum" %> <% if workshops.any? %> <%= render "dashboard/workshops_carousel", workshops: workshops %>
- <%= link_to "View all workshops", + <%= link_to "Search all workshops", workshops_path, class: "text-primary font-medium hover:underline" %>
diff --git a/app/views/dashboard/admin.html.erb b/app/views/dashboard/admin.html.erb index d18f7915b..72d5a80cb 100644 --- a/app/views/dashboard/admin.html.erb +++ b/app/views/dashboard/admin.html.erb @@ -6,12 +6,12 @@
- System settings + Admin-generated <% @system_cards.each do |card| %> + hover:<%= card[:hover_bg_color] %> transition-colors transition-shadow duration-300 p-4">
<%= card[:icon] %>
@@ -26,13 +26,12 @@
- User-generated content + User-generated <% @user_content_cards.each do |card| %> - + hover:<%= card[:hover_bg_color] %> transition-colors transition-shadow duration-300 p-4">
<%= card[:icon] %>
@@ -53,7 +52,7 @@
+ hover:<%= card[:hover_bg_color] %> transition-colors transition-shadow duration-300 p-4">
<%= card[:icon] %> @@ -71,15 +70,23 @@ <% end %> @@ -117,7 +117,7 @@

- <%= @event.description.presence || "—" %> + <%= @event.detail.presence || "—" %>

diff --git a/app/views/facilitators/_search_boxes.html.erb b/app/views/facilitators/_search_boxes.html.erb index 43888cdfc..ac3a7d45f 100644 --- a/app/views/facilitators/_search_boxes.html.erb +++ b/app/views/facilitators/_search_boxes.html.erb @@ -19,9 +19,9 @@
- <%= label_tag :sector_name, "Sector name(s)", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= label_tag :sector_names, "Sector name(s)", class: "block text-sm font-medium text-gray-700 mb-1" %>
- <%= text_field_tag :sector_name, params[:sector_name], + <%= text_field_tag :sector_names, params[:sector_names], class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", placeholder: "", diff --git a/app/views/quotes/index.html.erb b/app/views/quotes/index.html.erb index 28b9100b8..fdb3eb608 100644 --- a/app/views/quotes/index.html.erb +++ b/app/views/quotes/index.html.erb @@ -49,7 +49,7 @@ <%# end %>
- <%= link_to h(quote.description), quote_path(quote.object), class: "hover:underline" %> + <%= link_to h(quote.detail), quote_path(quote.object), class: "hover:underline" %>
<%= "-- " %> diff --git a/app/views/quotes/show.html.erb b/app/views/quotes/show.html.erb index 54536c9d6..fb6840264 100644 --- a/app/views/quotes/show.html.erb +++ b/app/views/quotes/show.html.erb @@ -33,7 +33,7 @@
- <%= @quote.description %> + <%= @quote.detail %>
-- <%= @quote.attribution %> diff --git a/app/views/sectors/_tagging_label.html.erb b/app/views/sectors/_tagging_label.html.erb new file mode 100644 index 000000000..1fc5b9fbd --- /dev/null +++ b/app/views/sectors/_tagging_label.html.erb @@ -0,0 +1,22 @@ +<% sector ||= nil %> +<% is_leader ||= false %> +<% display_leader ||= false %> + +<% if sector %> + + <%= link_to taggings_path(sector_names: sector.name), + class: "inline-flex items-center + rounded-md + border border-gray-300 + bg-white + text-gray-500 + px-3 py-1 + text-sm font-medium + hover:bg-gray-100 hover:border-gray-400 + transition" do %> + <%= sector.name %> + <% if display_leader && is_leader %> + (Leader) + <% end %> + <% end %> +<% end %> diff --git a/app/views/shared/_navbar_menu.html.erb b/app/views/shared/_navbar_menu.html.erb index 70b5881cd..eb7dc49cf 100644 --- a/app/views/shared/_navbar_menu.html.erb +++ b/app/views/shared/_navbar_menu.html.erb @@ -4,7 +4,7 @@ <%# end %> - +
diff --git a/app/views/shared/_navbar_menu_mobile.html.erb b/app/views/shared/_navbar_menu_mobile.html.erb index b10864f76..b1962d4b3 100644 --- a/app/views/shared/_navbar_menu_mobile.html.erb +++ b/app/views/shared/_navbar_menu_mobile.html.erb @@ -1,17 +1,17 @@