diff --git a/app/assets/javascripts/better_together/controllers/event_datetime_controller.js b/app/assets/javascripts/better_together/controllers/event_datetime_controller.js deleted file mode 100644 index 32be1f825..000000000 --- a/app/assets/javascripts/better_together/controllers/event_datetime_controller.js +++ /dev/null @@ -1,127 +0,0 @@ -// Event DateTime Controller -// Handles dynamic synchronization between start time, end time, and duration fields -// -// Behavior: -// - When start time changes: Updates end time based on current duration -// - When end time changes: Updates duration based on start/end time difference -// - When duration changes: Updates end time based on start time + duration -// - Validates minimum duration (5 minutes) -// - Defaults duration to 30 minutes when not set - -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["startTime", "endTime", "duration"] - - connect() { - // Set default duration if not already set - if (!this.durationTarget.value || this.durationTarget.value === "0") { - this.durationTarget.value = "30" - } - - // Initialize end time if start time is set but end time is not - if (this.startTimeTarget.value && !this.endTimeTarget.value) { - this.updateEndTimeFromDuration() - } - } - - // Called when start time changes - updateEndTime() { - if (!this.startTimeTarget.value) { - this.endTimeTarget.value = "" - return - } - - // Use current duration or default to 30 minutes - const duration = this.getDurationInMinutes() - this.calculateEndTime(duration) - } - - // Called when end time changes - updateDuration() { - if (!this.startTimeTarget.value || !this.endTimeTarget.value) { - return - } - - const startTime = new Date(this.startTimeTarget.value) - const endTime = new Date(this.endTimeTarget.value) - - // Validate end time is after start time - if (endTime <= startTime) { - this.showTemporaryError("End time must be after start time") - return - } - - // Calculate duration in minutes - const diffInMs = endTime.getTime() - startTime.getTime() - const diffInMinutes = Math.round(diffInMs / (1000 * 60)) - - // Enforce minimum duration - if (diffInMinutes < 5) { - this.durationTarget.value = "5" - this.calculateEndTime(5) - } else { - this.durationTarget.value = diffInMinutes.toString() - } - } - - // Called when duration changes - updateEndTimeFromDuration() { - if (!this.startTimeTarget.value) { - return - } - - const duration = this.getDurationInMinutes() - - // Enforce minimum duration - if (duration < 5) { - this.durationTarget.value = "5" - this.calculateEndTime(5) - } else { - this.calculateEndTime(duration) - } - } - - // Helper methods - getDurationInMinutes() { - const duration = parseInt(this.durationTarget.value) || 30 - return Math.max(duration, 5) // Minimum 5 minutes - } - - calculateEndTime(durationMinutes) { - if (!this.startTimeTarget.value) return - - const startTime = new Date(this.startTimeTarget.value) - const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000)) - - // Format for datetime-local input (YYYY-MM-DDTHH:MM) - const year = endTime.getFullYear() - const month = String(endTime.getMonth() + 1).padStart(2, '0') - const day = String(endTime.getDate()).padStart(2, '0') - const hours = String(endTime.getHours()).padStart(2, '0') - const minutes = String(endTime.getMinutes()).padStart(2, '0') - - this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}` - } - - showTemporaryError(message) { - // Create or update error message - let errorElement = this.element.querySelector('.datetime-sync-error') - - if (!errorElement) { - errorElement = document.createElement('div') - errorElement.className = 'alert alert-warning datetime-sync-error mt-2' - errorElement.setAttribute('role', 'alert') - this.element.appendChild(errorElement) - } - - errorElement.textContent = message - - // Remove error after 3 seconds - setTimeout(() => { - if (errorElement && errorElement.parentNode) { - errorElement.parentNode.removeChild(errorElement) - } - }, 3000) - } -} diff --git a/app/assets/stylesheets/better_together/application.scss b/app/assets/stylesheets/better_together/application.scss index 641bafa30..b65c6764f 100644 --- a/app/assets/stylesheets/better_together/application.scss +++ b/app/assets/stylesheets/better_together/application.scss @@ -36,6 +36,7 @@ @use 'metrics'; @use 'navigation'; @use 'notifications'; +@use 'pagination'; @use 'profiles'; @use 'share'; @use 'sidebar_nav'; diff --git a/app/assets/stylesheets/better_together/pagination.scss b/app/assets/stylesheets/better_together/pagination.scss new file mode 100644 index 000000000..1282a3ee6 --- /dev/null +++ b/app/assets/stylesheets/better_together/pagination.scss @@ -0,0 +1,79 @@ +/* Better Together Pagination Styles */ +.pagination { + --bs-pagination-padding-x: 0.75rem; + --bs-pagination-padding-y: 0.5rem; + --bs-pagination-font-size: 0.875rem; + --bs-pagination-color: var(--bs-link-color); + --bs-pagination-bg: transparent; + --bs-pagination-border-width: 0; + --bs-pagination-border-color: transparent; + --bs-pagination-border-radius: 0.375rem; + --bs-pagination-hover-color: var(--bs-primary); + --bs-pagination-hover-bg: var(--bs-gray-100); + --bs-pagination-hover-border-color: transparent; + --bs-pagination-focus-color: var(--bs-primary); + --bs-pagination-focus-bg: var(--bs-gray-100); + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: var(--bs-primary); + --bs-pagination-active-border-color: var(--bs-primary); + --bs-pagination-disabled-color: var(--bs-secondary-color); + --bs-pagination-disabled-bg: transparent; + --bs-pagination-disabled-border-color: transparent; +} + +.pagination-info { + display: flex; + align-items: center; + min-height: 2rem; +} + +.pagination-info small { + font-weight: 500; + letter-spacing: 0.025em; +} + +/* Responsive pagination */ +@media (max-width: 768px) { + .pagination-info { + margin-bottom: 0.75rem; + text-align: center; + width: 100%; + } + + .pagination { + justify-content: center !important; + } + + .d-flex.justify-content-between.align-items-center.flex-wrap { + flex-direction: column; + align-items: center; + } +} + +/* Enhanced focus states for accessibility */ +.page-link:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.page-link:focus-visible { + box-shadow: var(--bs-pagination-focus-box-shadow); + outline: 2px solid var(--bs-primary); + outline-offset: 2px; +} + +/* Smooth transitions */ +.page-link { + transition: all 0.15s ease-in-out; +} + +/* Card styling for pagination container */ +.pagination .card { + background: var(--bs-gray-50); + border: 1px solid var(--bs-border-color-translucent); +} + +.pagination .card-body { + background: transparent; +} \ No newline at end of file diff --git a/app/controllers/better_together/events_controller.rb b/app/controllers/better_together/events_controller.rb index 8b14130c0..980673900 100644 --- a/app/controllers/better_together/events_controller.rb +++ b/app/controllers/better_together/events_controller.rb @@ -325,7 +325,6 @@ def preload_event_associations! # rubocop:todo Metrics/CyclomaticComplexity, Met # Preload translations for the event itself @event.string_translations.load - @event.text_translations.load # Preload cover image attachment to avoid attachment queries @event.cover_image_attachment&.blob&.load if @event.cover_image.attached? diff --git a/app/controllers/better_together/pages_controller.rb b/app/controllers/better_together/pages_controller.rb index 81d0361c4..21cf6ca7a 100644 --- a/app/controllers/better_together/pages_controller.rb +++ b/app/controllers/better_together/pages_controller.rb @@ -14,14 +14,20 @@ class PagesController < FriendlyResourceController # rubocop:todo Metrics/ClassL def index authorize resource_class - @pages = resource_collection + + @pages = build_filtered_collection + @pages = apply_sorting(@pages) + @pages = @pages.page(params[:page]).per(25) end def show # Hide pages that don't exist or aren't viewable to the current user as 404s render_not_found and return if @page.nil? - @content_blocks = @page.content_blocks + # Preload content blocks with their associations for better performance + @content_blocks = @page.content_blocks.includes( + background_image_file_attachment: :blob + ) @layout = 'layouts/better_together/page' @layout = @page.layout if @page.layout.present? end @@ -141,29 +147,13 @@ def safe_page_redirect_url def set_page @page = set_resource_instance + return unless @page + + @page = preload_page_associations(@page) rescue ActiveRecord::RecordNotFound render_not_found && return end - def page_params # rubocop:todo Metrics/MethodLength - params.require(:page).permit( - :meta_description, :keywords, :published_at, :sidebar_nav_id, - :privacy, :layout, :template, *Page.localized_attribute_list, - *Page.extra_permitted_attributes, - page_blocks_attributes: [ - :id, :position, :_destroy, - { - block_attributes: [ - :id, :type, :identifier, :_destroy, - *BetterTogether::Content::Block.localized_block_attributes, - *BetterTogether::Content::Block.storext_keys, - *BetterTogether::Content::Block.extra_permitted_attributes - ] - } - ] - ) - end - def resource_class ::BetterTogether::Page end @@ -172,8 +162,92 @@ def resource_collection policy_scope(resource_class) end + def apply_sorting(collection) + sort_by = params[:sort_by] + sort_direction = params[:sort_direction] == 'desc' ? :desc : :asc + + case sort_by + when 'title', 'slug' + collection.i18n.order(sort_by.to_sym => sort_direction) + else + collection.order(collection.arel_table[:identifier].send(sort_direction)) + end + end + + def build_filtered_collection + collection = base_collection + collection = apply_title_filter(collection) if params[:title_filter].present? + collection = apply_slug_filter(collection) if params[:slug_filter].present? + collection + end + def translatable_conditions [] end + + def base_collection + resource_collection.includes( + :string_translations, + page_blocks: { + block: [{ background_image_file_attachment: :blob }] + } + ) + end + + def apply_title_filter(collection) + search_term = params[:title_filter].strip + collection.i18n { title.matches("%#{search_term}%") } + end + + def apply_slug_filter(collection) + search_term = params[:slug_filter].strip + collection.i18n { slug.matches("%#{search_term}%") } + end + + def preload_page_associations(page) + resource_class.includes(page_includes).find(page.id) + end + + def page_includes + [ + :string_translations, + :sidebar_nav, + { page_blocks: { + block: [{ background_image_file_attachment: :blob }] + } } + ] + end + + def page_params + params.require(:page).permit( + basic_page_attributes + page_blocks_permitted_attributes + ) + end + + def basic_page_attributes + [ + :meta_description, :keywords, :published_at, :sidebar_nav_id, + :privacy, :layout, :template, *Page.localized_attribute_list, + *Page.extra_permitted_attributes + ] + end + + def page_blocks_permitted_attributes + [ + page_blocks_attributes: [ + :id, :position, :_destroy, + { block_attributes: block_permitted_attributes } + ] + ] + end + + def block_permitted_attributes + [ + :id, :type, :identifier, :_destroy, + *BetterTogether::Content::Block.localized_block_attributes, + *BetterTogether::Content::Block.storext_keys, + *BetterTogether::Content::Block.extra_permitted_attributes + ] + end end end diff --git a/app/controllers/better_together/platform_invitations_controller.rb b/app/controllers/better_together/platform_invitations_controller.rb index 2f0e33572..6eb5a3599 100644 --- a/app/controllers/better_together/platform_invitations_controller.rb +++ b/app/controllers/better_together/platform_invitations_controller.rb @@ -7,6 +7,32 @@ class PlatformInvitationsController < ApplicationController # rubocop:todo Style before_action :set_platform_invitation, only: %i[destroy resend] after_action :verify_authorized + before_action only: %i[index], if: -> { Rails.env.development? } do + # Make sure that all Platform Invitation subclasses are loaded in dev to generate new block buttons + ::BetterTogether::PlatformInvitation.load_all_subclasses + end + + # GET /platforms/:platform_id/platform_invitations + def index # rubocop:todo Metrics/MethodLength + authorize BetterTogether::PlatformInvitation + + # Build filtered and sorted collection with pagination + @platform_invitations = build_filtered_collection + @platform_invitations = apply_sorting(@platform_invitations) + @platform_invitations = @platform_invitations.page(params[:page]).per(25) + + # Preload roles for the form to prevent N+1 queries during rendering + @community_roles = BetterTogether::Role.where(resource_type: 'BetterTogether::Community') + .includes(:string_translations) + .order(:position) + @platform_roles = BetterTogether::Role.where(resource_type: 'BetterTogether::Platform') + .includes(:string_translations) + .order(:position) + + # Find the default community member role for the hidden field + @default_community_role = @community_roles.find_by(identifier: 'community_member') + end + # POST /platforms/:platform_id/platform_invitations def create # rubocop:todo Metrics/AbcSize, Metrics/MethodLength @platform_invitation = @platform.invitations.new(platform_invitation_params) do |pi| @@ -19,29 +45,34 @@ def create # rubocop:todo Metrics/AbcSize, Metrics/MethodLength authorize @platform_invitation - respond_to do |format| + respond_to do |format| # rubocop:todo Metrics/BlockLength if @platform_invitation.save flash[:notice] = t('flash.generic.created', resource: t('resources.invitation')) format.html { redirect_to @platform, notice: flash[:notice] } format.turbo_stream do render turbo_stream: [ turbo_stream.prepend('platform_invitations_table_body', - # rubocop:todo Layout/LineLength - partial: 'better_together/platform_invitations/platform_invitation', locals: { platform_invitation: @platform_invitation }), - # rubocop:enable Layout/LineLength - turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', - locals: { flash: }) + partial: 'better_together/platform_invitations/platform_invitation', + locals: { platform_invitation: @platform_invitation }), + turbo_stream.replace('flash_messages', + partial: 'layouts/better_together/flash_messages', + locals: { flash: }) ] end else flash.now[:alert] = t('flash.generic.error_create', resource: t('resources.invitation')) - format.html { redirect_to @platform, alert: @platform_invitation.errors.full_messages.to_sentence } + format.html do + index + render :index, status: :unprocessable_entity + end format.turbo_stream do render turbo_stream: [ - turbo_stream.update('form_errors', partial: 'layouts/better_together/errors', - locals: { object: @platform_invitation }), - turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', - locals: { flash: }) + turbo_stream.update('form_errors', + partial: 'layouts/better_together/errors', + locals: { object: @platform_invitation }), + turbo_stream.replace('flash_messages', + partial: 'layouts/better_together/flash_messages', + locals: { flash: }) ] end end @@ -54,24 +85,24 @@ def destroy # rubocop:todo Metrics/AbcSize, Metrics/MethodLength if @platform_invitation.destroy flash.now[:notice] = t('flash.generic.removed', resource: t('resources.invitation')) respond_to do |format| - format.html { redirect_to @platform } + format.html { redirect_to platform_platform_invitations_path(@platform) } format.turbo_stream do render turbo_stream: [ turbo_stream.remove(helpers.dom_id(@platform_invitation)), - turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', - locals: { flash: }) + turbo_stream.replace('flash_messages', + partial: 'layouts/better_together/flash_messages', + locals: { flash: }) ] end end else flash.now[:error] = t('flash.generic.error_remove', resource: t('resources.invitation')) respond_to do |format| - format.html { redirect_to @platform, alert: flash.now[:error] } + format.html { redirect_to platform_platform_invitations_path(@platform), alert: flash.now[:error] } format.turbo_stream do - # rubocop:todo Layout/LineLength - render turbo_stream: turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', - # rubocop:enable Layout/LineLength - locals: { flash: }) + render turbo_stream: turbo_stream.replace('flash_messages', + partial: 'layouts/better_together/flash_messages', + locals: { flash: }) end end end @@ -85,14 +116,15 @@ def resend # rubocop:todo Metrics/AbcSize, Metrics/MethodLength flash[:notice] = t('flash.generic.queued', resource: t('resources.invitation_email')) respond_to do |format| - format.html { redirect_to @platform, notice: flash[:notice] } + format.html { redirect_to platform_platform_invitations_path(@platform), notice: flash[:notice] } format.turbo_stream do render turbo_stream: [ turbo_stream.replace(helpers.dom_id(@platform_invitation), partial: 'better_together/platform_invitations/platform_invitation', locals: { platform_invitation: @platform_invitation }), - turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages', - locals: { flash: }) + turbo_stream.replace('flash_messages', + partial: 'layouts/better_together/flash_messages', + locals: { flash: }) ] end end @@ -113,6 +145,119 @@ def set_platform_invitation @platform_invitation = @platform.invitations.find(params[:id]) end + def build_filtered_collection # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity + collection = base_collection + collection = apply_status_filter(collection) if filter_params[:status].present? || params[:status].present? + collection = apply_email_filter(collection) if filter_params[:search].present? || params[:search].present? + collection = apply_valid_from_filter(collection) if filter_params[:valid_from].present? + collection = apply_valid_until_filter(collection) if filter_params[:valid_until].present? + collection = apply_accepted_at_filter(collection) if filter_params[:accepted_at].present? + collection = apply_last_sent_filter(collection) if filter_params[:last_sent].present? + collection + end + + def apply_sorting(collection) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength + sort_by = params[:sort_by] + sort_direction = params[:sort_direction] == 'asc' ? :asc : :desc + + default_sort = { created_at: sort_direction } + + case sort_by + when 'invitee_email' + collection.order({ invitee_email: sort_direction }.merge(default_sort)) + when 'status' + collection.order({ status: sort_direction }.merge(default_sort)) + when 'created_at' + collection.order({ created_at: sort_direction }.merge(default_sort)) + when 'valid_from' + collection.order({ valid_from: sort_direction }.merge(default_sort)) + when 'valid_until' + collection.order({ valid_until: sort_direction }.merge(default_sort)) + when 'accepted_at' + collection.order({ accepted_at: sort_direction }.merge(default_sort)) + when 'last_sent' + collection.order({ last_sent: sort_direction }.merge(default_sort)) + else + # Default sort by created_at (newest first) + collection.order(created_at: :desc) + end + end + + def base_collection + policy_scope(@platform.invitations).includes( + { inviter: [:string_translations] }, + { invitee: [:string_translations] } + ) + end + + def apply_status_filter(collection) + status = filter_params[:status] || params[:status] + collection.where(status: status) if status.present? + end + + def apply_email_filter(collection) + search_term = filter_params[:search] || params[:search] + return collection unless search_term.present? + + collection.where('invitee_email ILIKE ?', "%#{search_term.strip}%") + end + + def apply_valid_from_filter(collection) + date_filter = filter_params[:valid_from] + return collection unless date_filter.present? + + apply_datetime_filter(collection, :valid_from, date_filter) + end + + def apply_valid_until_filter(collection) + date_filter = filter_params[:valid_until] + return collection unless date_filter.present? + + apply_datetime_filter(collection, :valid_until, date_filter) + end + + def apply_accepted_at_filter(collection) + date_filter = filter_params[:accepted_at] + return collection unless date_filter.present? + + apply_datetime_filter(collection, :accepted_at, date_filter) + end + + def apply_last_sent_filter(collection) + date_filter = filter_params[:last_sent] + return collection unless date_filter.present? + + apply_datetime_filter(collection, :last_sent, date_filter) + end + + def apply_datetime_filter(collection, column, date_filter) + return collection unless date_filter.is_a?(Hash) + + if date_filter[:from].present? + from_date = parse_date(date_filter[:from]) + collection = collection.where("#{column} >= ?", from_date.beginning_of_day) if from_date + end + + if date_filter[:to].present? + to_date = parse_date(date_filter[:to]) + collection = collection.where("#{column} <= ?", to_date.end_of_day) if to_date + end + + collection + end + + def parse_date(date_string) + return nil unless date_string.present? + + Date.parse(date_string.to_s) + rescue ArgumentError + nil + end + + def filter_params + params[:filters] || {} + end + def platform_invitation_params params.require(:platform_invitation).permit( :invitee_email, :platform_role_id, :community_role_id, :locale, @@ -122,11 +267,13 @@ def platform_invitation_params end def param_invitation_class - param_type = params[:platform_invitation][:type] + param_type = params[:platform_invitation]&.[](:type) Rails.application.eager_load! unless Rails.env.production? # Ensure all models are loaded valid_types = [BetterTogether::PlatformInvitation, *BetterTogether::PlatformInvitation.descendants] - valid_types.find { |klass| klass.to_s == param_type } + found_class = valid_types.find { |klass| klass.to_s == param_type } + + found_class || BetterTogether::PlatformInvitation end end # rubocop:enable Metrics/ClassLength diff --git a/app/controllers/better_together/platforms_controller.rb b/app/controllers/better_together/platforms_controller.rb index fb6d02e6d..25c3b39cf 100644 --- a/app/controllers/better_together/platforms_controller.rb +++ b/app/controllers/better_together/platforms_controller.rb @@ -1,16 +1,11 @@ # frozen_string_literal: true module BetterTogether - class PlatformsController < FriendlyResourceController # rubocop:todo Style/Documentation + class PlatformsController < FriendlyResourceController # rubocop:todo Style/Documentation, Metrics/ClassLength before_action :set_platform, only: %i[show edit update destroy] before_action :authorize_platform, only: %i[show edit update destroy] after_action :verify_authorized, except: :index - before_action only: %i[show], if: -> { Rails.env.development? } do - # Make sure that all Platform Invitation subclasses are loaded in dev to generate new block buttons - ::BetterTogether::PlatformInvitation.load_all_subclasses - end - # GET /platforms def index # @platforms = ::BetterTogether::Platform.all @@ -22,6 +17,9 @@ def index # GET /platforms/1 def show authorize @platform + # Preload memberships with policy scope applied to prevent N+1 queries in view + # Include comprehensive associations for members and roles to eliminate N+1 queries + @platform_memberships = policy_scope(@platform.memberships_with_associations) end # GET /platforms/new @@ -128,8 +126,48 @@ def resource_class ::BetterTogether::Platform end - def resource_collection - resource_class.includes(:invitations, { person_platform_memberships: %i[member role] }) + def resource_collection # rubocop:todo Metrics/MethodLength + # Comprehensive eager loading to prevent N+1 queries across all platform associations + resource_class.includes( + # Platform's own translations and attachments + :string_translations, + :text_translations, + cover_image_attachment: { blob: :variant_records }, + profile_image_attachment: { blob: :variant_records }, + + # Community association with its own attachments + community: [ + :string_translations, + :text_translations, + { profile_image_attachment: { blob: :variant_records } }, + { cover_image_attachment: { blob: :variant_records } } + ], + + # Content blocks + platform_blocks: { + block: %i[ + string_translations + text_translations + ] + }, + + # Person platform memberships with all necessary nested associations + person_platform_memberships: [ + { + member: [ + :string_translations, + :text_translations, + { profile_image_attachment: { blob: :variant_records } } + ] + }, + { + role: %i[ + string_translations + text_translations + ] + } + ] + ) end end end diff --git a/app/helpers/better_together/image_helper.rb b/app/helpers/better_together/image_helper.rb index 92d8e2067..453fe6f14 100644 --- a/app/helpers/better_together/image_helper.rb +++ b/app/helpers/better_together/image_helper.rb @@ -119,18 +119,26 @@ def profile_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength, # Determine if entity has a profile image if entity.respond_to?(:profile_image) && entity.profile_image.attached? - attachment = if entity.respond_to?(:optimized_profile_image) - entity.optimized_profile_image - else - entity.profile_image_variant(image_size) - end - - image_tag(rails_storage_proxy_url(attachment), **image_tag_attributes) + # Use optimized URL method that doesn't block on .processed + image_url = if entity.respond_to?(:profile_image_url) + entity.profile_image_url(size: image_size) + elsif entity.respond_to?(:optimized_profile_image) + rails_storage_proxy_url(entity.optimized_profile_image) + else + # Fallback to variant without calling .processed + rails_storage_proxy_url(entity.profile_image_variant(image_size)) + end + + image_tag(image_url, **image_tag_attributes) if image_url else # Use a default image based on the entity type default_image = default_profile_image(entity, image_format) image_tag(image_url(default_image), **image_tag_attributes) end + rescue ActiveStorage::FileNotFoundError + # Use a default image based on the entity type + default_image = default_profile_image(entity, image_format) + image_tag(image_url(default_image), **image_tag_attributes) end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity diff --git a/app/helpers/better_together/pages_helper.rb b/app/helpers/better_together/pages_helper.rb index 092aa25c6..a9d5e5847 100644 --- a/app/helpers/better_together/pages_helper.rb +++ b/app/helpers/better_together/pages_helper.rb @@ -1,11 +1,132 @@ # frozen_string_literal: true module BetterTogether - module PagesHelper # rubocop:todo Style/Documentation + # rubocop:todo Metrics/ModuleLength + module PagesHelper # rubocop:todo Style/Documentation, Metrics/ModuleLength def render_page_content(page) - Rails.cache.fetch(['page_content', page.cache_key_with_version], expires_in: 1.minute) do - render @page.content_blocks + # rubocop:todo Layout/IndentationWidth + Rails.cache.fetch(['page_content', page.cache_key_with_version], expires_in: 1.minute) do + # rubocop:enable Layout/IndentationWidth + render @page.content_blocks + end + end + + def pages_cache_key(pages) + base_cache_elements(pages) + filter_cache_elements + version_cache_element + end + + def base_cache_elements(pages) + [ + 'pages-index', + pages.maximum(:updated_at), + pages.current_page, + pages.total_pages, + pages.size, + current_user&.id, + I18n.locale + ] + end + + def filter_cache_elements + [ + params[:title_filter], + params[:slug_filter], + params[:sort_by], + params[:sort_direction] + ] + end + + def version_cache_element + ['v1'] + end + + def page_row_cache_key(page) + [ + 'page-row', + page.id, + page.updated_at, + page.page_blocks.maximum(:updated_at), + current_user&.id, + I18n.locale, + 'v1' + ] + end + + def page_show_cache_key(page) + [ + 'page-show', + page.id, + page.updated_at, + page.page_blocks.maximum(:updated_at), + page.blocks.maximum(:updated_at), + current_user&.id, + I18n.locale, + 'v1' + ] + end + + def sortable_column_header(column, label) + sort_info = calculate_sort_info(column) + + link_to build_sort_path(column, sort_info[:direction]), sort_link_options do + build_sort_content(label, sort_info[:icon_class]) end end + + def calculate_sort_info(column) + if currently_sorted_by?(column) + active_column_sort_info + else + default_column_sort_info + end + end + + def currently_sorted_by?(column) + params[:sort_by] == column.to_s + end + + def active_column_sort_info + current_direction = params[:sort_direction] + { + direction: current_direction == 'asc' ? 'desc' : 'asc', + icon_class: current_direction == 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down' + } + end + + def default_column_sort_info + { + direction: 'asc', + icon_class: 'fas fa-sort text-muted' + } + end + + def build_sort_path(column, direction) + pages_path( + title_filter: params[:title_filter], + sort_by: column, + sort_direction: direction, + page: params[:page] + ) + end + + def sort_link_options + { class: 'text-decoration-none d-flex align-items-center justify-content-between' } + end + + def build_sort_content(label, icon_class) + safe_join([ + content_tag(:span, label), + content_tag(:i, '', class: icon_class, 'aria-hidden': true) + ]) + end + + def current_title_filter + params[:title_filter] || '' + end + + def current_slug_filter + params[:slug_filter] || '' + end end + # rubocop:enable Metrics/ModuleLength end diff --git a/app/helpers/better_together/platform_invitations_helper.rb b/app/helpers/better_together/platform_invitations_helper.rb new file mode 100644 index 000000000..3fbda106b --- /dev/null +++ b/app/helpers/better_together/platform_invitations_helper.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module BetterTogether + # Helper methods for platform invitations views + module PlatformInvitationsHelper + def sortable_column_header_for_invitations(column, label, platform) + sort_info = calculate_sort_info_for_invitations(column) + + link_to build_sort_path_for_invitations(column, sort_info[:direction], platform), + sort_link_options_for_invitations do + build_sort_content_for_invitations(label, sort_info[:icon_class]) + end + end + + def calculate_sort_info_for_invitations(column) + if currently_sorted_by_invitations?(column) + active_column_sort_info_for_invitations + else + default_column_sort_info_for_invitations + end + end + + def currently_sorted_by_invitations?(column) + params[:sort_by] == column.to_s + end + + def active_column_sort_info_for_invitations + current_direction = params[:sort_direction] + { + direction: current_direction == 'asc' ? 'desc' : 'asc', + icon_class: current_direction == 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down' + } + end + + def default_column_sort_info_for_invitations + { + direction: 'asc', + icon_class: 'fas fa-sort text-muted' + } + end + + def build_sort_path_for_invitations(column, direction, platform) # rubocop:todo Metrics/MethodLength + platform_platform_invitations_path(platform, + filters: { + search: current_search_filter_for_invitations, + status: current_status_filter_for_invitations, + valid_from: current_valid_from_filter_for_invitations.presence, + valid_until: current_valid_until_filter_for_invitations.presence, + accepted_at: current_accepted_at_filter_for_invitations.presence, + last_sent: current_last_sent_filter_for_invitations.presence + }.compact_blank, + sort_by: column, + sort_direction: direction, + page: params[:page]) + end + + def sort_link_options_for_invitations + { + class: 'text-decoration-none d-flex align-items-center justify-content-between', + data: { + turbo_frame: 'platform_invitations_content', + turbo_prefetch: false # disable Turbo prefetch for these links + } + } + end + + def build_sort_content_for_invitations(label, icon_class) + safe_join([ + content_tag(:span, label), + content_tag(:i, '', class: icon_class, 'aria-hidden': true) + ]) + end + + def current_search_filter_for_invitations + filter_params[:search] || params[:search] + end + + def current_status_filter_for_invitations + filter_params[:status] || params[:status] + end + + def current_valid_from_filter_for_invitations + filter = filter_params[:valid_from] || {} + { + from: filter[:from].presence, + to: filter[:to].presence + }.compact + end + + def current_valid_until_filter_for_invitations + filter = filter_params[:valid_until] || {} + { + from: filter[:from].presence, + to: filter[:to].presence + }.compact + end + + def current_accepted_at_filter_for_invitations + filter = filter_params[:accepted_at] || {} + { + from: filter[:from].presence, + to: filter[:to].presence + }.compact + end + + def current_last_sent_filter_for_invitations + filter = filter_params[:last_sent] || {} + { + from: filter[:from].presence, + to: filter[:to].presence + }.compact + end + + private + + def filter_params + params[:filters] || {} + end + end +end diff --git a/app/javascript/controllers/better_together/auto_submit_controller.js b/app/javascript/controllers/better_together/auto_submit_controller.js new file mode 100644 index 000000000..98a0f6b35 --- /dev/null +++ b/app/javascript/controllers/better_together/auto_submit_controller.js @@ -0,0 +1,47 @@ +import { Controller } from "@hotwired/stimulus" + +// Auto-submit forms with a debounce delay +// +// Usage: +//
+// +//
+export default class extends Controller { + static values = { delay: { type: Number, default: 300 } } + + connect() { + this.timeout = null + + // Auto-attach to all input fields in the form + this.element.querySelectorAll('input[type="text"], input[type="search"], select, textarea').forEach(input => { + input.addEventListener('input', this.scheduleSubmit.bind(this)) + input.addEventListener('change', this.scheduleSubmit.bind(this)) + }) + } + + disconnect() { + this.clearTimeout() + } + + scheduleSubmit() { + this.clearTimeout() + + this.timeout = setTimeout(() => { + this.submit() + }, this.delayValue) + } + + submit() { + this.clearTimeout() + + // Submit the form + this.element.requestSubmit() + } + + clearTimeout() { + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + } +} \ No newline at end of file diff --git a/app/assets/javascripts/better_together/controllers/person_search_controller.js b/app/javascript/controllers/better_together/person_search_controller.js similarity index 100% rename from app/assets/javascripts/better_together/controllers/person_search_controller.js rename to app/javascript/controllers/better_together/person_search_controller.js diff --git a/app/models/better_together/calendar.rb b/app/models/better_together/calendar.rb index ea3b8bdd4..39f4f86de 100644 --- a/app/models/better_together/calendar.rb +++ b/app/models/better_together/calendar.rb @@ -17,7 +17,7 @@ class Calendar < ApplicationRecord slugged :name - translates :name + translates :name, type: :string translates :description, backend: :action_text def to_s diff --git a/app/models/better_together/call_for_interest.rb b/app/models/better_together/call_for_interest.rb index c255d75a8..3df3071bc 100644 --- a/app/models/better_together/call_for_interest.rb +++ b/app/models/better_together/call_for_interest.rb @@ -9,7 +9,7 @@ class CallForInterest < ApplicationRecord include Identifier include Privacy - translates :name + translates :name, type: :string translates :description, backend: :action_text slugged :name diff --git a/app/models/better_together/community.rb b/app/models/better_together/community.rb index 008170ef2..890655424 100644 --- a/app/models/better_together/community.rb +++ b/app/models/better_together/community.rb @@ -26,7 +26,7 @@ class Community < ApplicationRecord slugged :name - translates :name + translates :name, type: :string translates :description, type: :text translates :description_html, backend: :action_text diff --git a/app/models/better_together/event.rb b/app/models/better_together/event.rb index e16fda288..efeb46913 100644 --- a/app/models/better_together/event.rb +++ b/app/models/better_together/event.rb @@ -36,7 +36,7 @@ class Event < ApplicationRecord # delegate :geocoding_string, to: :address, allow_nil: true # geocoded_by :geocoding_string - translates :name + translates :name, type: :string translates :description, backend: :action_text slugged :name diff --git a/app/models/better_together/infrastructure/building.rb b/app/models/better_together/infrastructure/building.rb index 303885f38..d8aad74ba 100644 --- a/app/models/better_together/infrastructure/building.rb +++ b/app/models/better_together/infrastructure/building.rb @@ -42,7 +42,7 @@ class Building < ApplicationRecord after_create :schedule_address_geocoding after_update :schedule_address_geocoding - translates :name + translates :name, type: :string translates :description, backend: :action_text slugged :name diff --git a/app/models/better_together/infrastructure/floor.rb b/app/models/better_together/infrastructure/floor.rb index b8a00391d..5faa54286 100644 --- a/app/models/better_together/infrastructure/floor.rb +++ b/app/models/better_together/infrastructure/floor.rb @@ -20,7 +20,7 @@ class Floor < ApplicationRecord belongs_to :building, class_name: 'BetterTogether::Infrastructure::Building', touch: true has_many :rooms, class_name: 'BetterTogether::Infrastructure::Room', dependent: :destroy - translates :name + translates :name, type: :string translates :description, backend: :action_text slugged :name diff --git a/app/models/better_together/infrastructure/room.rb b/app/models/better_together/infrastructure/room.rb index 56db58fc7..6fdced5f0 100644 --- a/app/models/better_together/infrastructure/room.rb +++ b/app/models/better_together/infrastructure/room.rb @@ -19,7 +19,7 @@ class Room < ApplicationRecord delegate :level, to: :floor - translates :name + translates :name, type: :string translates :description, backend: :action_text slugged :name diff --git a/app/models/better_together/metrics/link_click.rb b/app/models/better_together/metrics/link_click.rb index 332cdc0d0..7cb6962ed 100644 --- a/app/models/better_together/metrics/link_click.rb +++ b/app/models/better_together/metrics/link_click.rb @@ -4,18 +4,42 @@ module BetterTogether module Metrics class LinkClick < ApplicationRecord # rubocop:todo Style/Documentation + include Utf8UrlHandler + # Validations VALID_URL_SCHEMES = %w[http https tel mailto].freeze - # Regular expression to match http, https, tel, and mailto URLs - VALID_URL_REGEX = /\A(http|https|tel|mailto):.+\z/ - - validates :url, presence: true, - format: { with: VALID_URL_REGEX, message: 'must be a valid URL or tel/mailto link' } - validates :page_url, presence: true, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) + validates :url, presence: true + validates :page_url, presence: true validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :clicked_at, presence: true validates :internal, inclusion: { in: [true, false] } + + # Custom validation for UTF-8 URL support + validate :url_must_be_valid + validate :page_url_must_be_valid + + private + + def url_must_be_valid + return if url.blank? + + return if valid_utf8_url?(url) + + errors.add(:url, 'must be a valid URL or tel/mailto link') + end + + def page_url_must_be_valid + return if page_url.blank? + + # For page_url, we're more lenient - it can be a relative path or full URL + uri = safe_parse_uri(page_url) + + # If it parses as a URI and either has no scheme (relative) or has http/https scheme + return if uri && (uri.scheme.nil? || %w[http https].include?(uri.scheme&.downcase)) + + errors.add(:page_url, 'must be a valid URL') + end end end end diff --git a/app/models/better_together/metrics/page_view.rb b/app/models/better_together/metrics/page_view.rb index aaab08833..89aad5a0d 100644 --- a/app/models/better_together/metrics/page_view.rb +++ b/app/models/better_together/metrics/page_view.rb @@ -4,6 +4,8 @@ module BetterTogether module Metrics class PageView < ApplicationRecord # rubocop:todo Style/Documentation + include Utf8UrlHandler + SENSITIVE_QUERY_PARAMS = %w[token password secret].freeze belongs_to :pageable, polymorphic: true @@ -34,11 +36,15 @@ def set_page_url # rubocop:todo Metrics/AbcSize, Metrics/MethodLength return if url.blank? - uri = URI.parse(url) - @page_url_query = uri.query - self.page_url = uri.path - rescue URI::InvalidURIError - errors.add(:page_url, 'is invalid') + # Use our UTF-8 safe URI parser + uri = safe_parse_uri(url) + if uri + @page_url_query = uri.query + self.page_url = uri.path + else + # If we can't parse it at all, add an error + errors.add(:page_url, 'is invalid') + end end def page_url_without_sensitive_parameters diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb index 9ffa762bd..a83e9b8cd 100644 --- a/app/models/better_together/person.rb +++ b/app/models/better_together/person.rb @@ -112,9 +112,24 @@ def email has_one_attached :profile_image has_one_attached :cover_image - # Resize the profile image before rendering + # Resize the profile image before rendering (non-blocking version) def profile_image_variant(size) - profile_image.variant(resize_to_fill: [size, size]).processed + return profile_image.variant(resize_to_fill: [size, size]) unless Rails.env.production? + + # In production, avoid blocking .processed calls + profile_image.variant(resize_to_fill: [size, size]) + end + + # Get optimized profile image variant without blocking rendering + def profile_image_url(size: 300) + return nil unless profile_image.attached? + + variant = profile_image.variant(resize_to_fill: [size, size]) + + # For better performance, use Rails URL helpers for variant + Rails.application.routes.url_helpers.url_for(variant) + rescue ActiveStorage::FileNotFoundError + nil end # Resize the cover image to specific dimensions @@ -184,7 +199,7 @@ def all_calendar_events # rubocop:todo Metrics/AbcSize, Metrics/MethodLength # Single query to fetch all events with necessary includes if event_ids.any? - Event.includes(:string_translations, :text_translations) + Event.includes(:string_translations) .where(id: event_ids.to_a) .to_a else diff --git a/app/models/better_together/platform.rb b/app/models/better_together/platform.rb index f1bab009d..e470ffb0e 100644 --- a/app/models/better_together/platform.rb +++ b/app/models/better_together/platform.rb @@ -23,6 +23,12 @@ class Platform < ApplicationRecord class_name: '::BetterTogether::PlatformInvitation', foreign_key: :invitable_id + # For performance - scope to limit invitations in some contexts + has_many :recent_invitations, + -> { where(created_at: 30.days.ago..) }, + class_name: '::BetterTogether::PlatformInvitation', + foreign_key: :invitable_id + slugged :name store_attributes :settings do @@ -74,6 +80,26 @@ def primary_community_extra_attrs { host:, protected: } end + # Efficiently load platform memberships with all necessary associations + # to prevent N+1 queries in views + def memberships_with_associations # rubocop:todo Metrics/MethodLength + person_platform_memberships.includes( + { + member: [ + :string_translations, + :text_translations, + { profile_image_attachment: { blob: { variant_records: [], preview_image_attachment: { blob: [] } } } } + ] + }, + { + role: %i[ + string_translations + text_translations + ] + } + ) + end + def to_s name end diff --git a/app/models/better_together/platform_invitation.rb b/app/models/better_together/platform_invitation.rb index 286bef8fd..507e80620 100644 --- a/app/models/better_together/platform_invitation.rb +++ b/app/models/better_together/platform_invitation.rb @@ -32,7 +32,7 @@ class PlatformInvitation < ApplicationRecord has_rich_text :greeting, encrypted: true - validates :invitee_email, uniqueness: { scope: :invitable_id, allow_nil: true } + validates :invitee_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :invitee_email, uniqueness: { scope: :invitable_id, allow_nil: true, allow_blank: true } validates :locale, presence: true, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :status, presence: true, inclusion: { in: STATUS_VALUES.values } @@ -84,6 +84,14 @@ def to_s "[#{self.class.model_name.human}] - #{id}" end + # Attributes permitted for strong parameters + def self.permitted_attributes(id: false, destroy: false) + super + %i[ + invitee_email platform_role_id community_role_id locale + valid_from valid_until greeting session_duration_mins + ] + end + private def set_accepted_timestamp diff --git a/app/models/better_together/role.rb b/app/models/better_together/role.rb index 6e57d497b..2f5069890 100644 --- a/app/models/better_together/role.rb +++ b/app/models/better_together/role.rb @@ -13,7 +13,7 @@ class Role < ApplicationRecord slugged :identifier, dependent: :delete_all - translates :name + translates :name, type: :string translates :description, type: :text validates :name, diff --git a/app/models/better_together/upload.rb b/app/models/better_together/upload.rb index fa1a39820..5a7f58e0b 100644 --- a/app/models/better_together/upload.rb +++ b/app/models/better_together/upload.rb @@ -12,7 +12,7 @@ class Upload < ApplicationRecord delegate :attached?, :byte_size, :content_type, :download, :filename, :url, to: :file - translates :name + translates :name, type: :string translates :description, backend: :action_text include RemoveableAttachment diff --git a/app/models/better_together/wizard.rb b/app/models/better_together/wizard.rb index 55b539d71..1b6c2810a 100644 --- a/app/models/better_together/wizard.rb +++ b/app/models/better_together/wizard.rb @@ -12,7 +12,7 @@ class Wizard < ApplicationRecord slugged :identifier, dependent: :delete_all - translates :name + translates :name, type: :string translates :description, type: :text validates :name, presence: true diff --git a/app/models/better_together/wizard_step_definition.rb b/app/models/better_together/wizard_step_definition.rb index f6afef40b..9e639e222 100644 --- a/app/models/better_together/wizard_step_definition.rb +++ b/app/models/better_together/wizard_step_definition.rb @@ -14,7 +14,7 @@ class WizardStepDefinition < ApplicationRecord slugged :identifier, dependent: :delete_all - translates :name + translates :name, type: :string translates :description, type: :text validates :name, presence: true diff --git a/app/models/concerns/better_together/infrastructure/building_connections.rb b/app/models/concerns/better_together/infrastructure/building_connections.rb index 4956f949b..252a6d44a 100644 --- a/app/models/concerns/better_together/infrastructure/building_connections.rb +++ b/app/models/concerns/better_together/infrastructure/building_connections.rb @@ -35,23 +35,22 @@ def leaflet_points # rubocop:todo Metrics/AbcSize, Metrics/MethodLength point = building.to_leaflet_point next if point.nil? + place_label = (" - #{building.address.text_label}" if building.address.text_label.present?) + + place_url = Rails.application.routes.url_helpers.polymorphic_path( + self, + locale: I18n.locale + ) + + place_link = "#{name}#{place_label}" + + address_label = building.address.to_formatted_s( + excluded: [:display_label] + ) + point.merge( - label: "#{name}#{if building.address.text_label.present? - building.address.text_label - end}", - popup_html: "#{name}#{if building.address.text_label.present? - " - #{building.address.text_label}" - end}
#{ - building.address.to_formatted_s( - excluded: [:display_label] - ) - }" + label: place_link, + popup_html: place_link + "
#{address_label}" ) end.compact end diff --git a/app/models/concerns/better_together/metrics/utf8_url_handler.rb b/app/models/concerns/better_together/metrics/utf8_url_handler.rb new file mode 100644 index 000000000..05c2a93ad --- /dev/null +++ b/app/models/concerns/better_together/metrics/utf8_url_handler.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module BetterTogether + module Metrics + # Helper module for handling UTF-8 URLs in metrics models + module Utf8UrlHandler + extend ActiveSupport::Concern + + private + + # Parse a URL that may contain UTF-8 characters + # @param url [String] The URL to parse + # @return [URI::Generic, nil] Parsed URI or nil if invalid + def safe_parse_uri(url) # rubocop:todo Metrics/MethodLength + return if url.blank? + + # First try with the URL as-is + begin + URI.parse(url) + rescue URI::InvalidURIError + # If that fails, try encoding it + encoded_url = encode_utf8_url(url) + begin + URI.parse(encoded_url) + rescue URI::InvalidURIError + nil + end + end + end + + # Encode UTF-8 characters in a URL while preserving the structure + # @param url [String] The URL to encode + # @return [String] URL-encoded string + def encode_utf8_url(url) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return url if url.blank? + + # Split URL into components to avoid encoding the protocol/scheme + if url.match(%r{\A([a-z]+://)}i) + scheme_and_authority, path_and_query = url.split('/', 3) + if path_and_query.present? + encoded_path = path_and_query.split('?').map do |part| + encode_utf8_component(part) + end.join('?') + "#{scheme_and_authority}/#{encoded_path}" + else + # Encode just the host part if no path + parts = url.split('//') + if parts.length > 1 + protocol = parts[0] + host_and_rest = parts[1] + "#{protocol}//#{encode_host_component(host_and_rest)}" + else + url + end + end + else + # No protocol, encode the whole thing + encode_utf8_component(url) + end + end + + # Encode UTF-8 characters in a URL component + # @param component [String] The URL component to encode + # @return [String] Encoded component + def encode_utf8_component(component) + return component if component.blank? + + # Only encode non-ASCII characters + component.gsub(/[^\x00-\x7F]/) { |char| CGI.escape(char) } + end + + # Encode UTF-8 characters in a host component (for IDN support) + # @param host_component [String] The host component to encode + # @return [String] Encoded host component + def encode_host_component(host_component) # rubocop:todo Metrics/MethodLength, Metrics/PerceivedComplexity + return host_component if host_component.blank? + + # For international domain names, we need special handling + # Split by '/' to separate host from path + parts = host_component.split('/', 2) + host = parts[0] + path = parts[1] + + # Convert international domain to punycode if needed + encoded_host = begin + # Try to convert to ASCII using Punycode for IDN support + if host.match?(/[^\x00-\x7F]/) + # For Ruby's built-in IDN support, we'll encode each part + host_parts = host.split('.') + encoded_parts = host_parts.map do |part| + if part.match?(/[^\x00-\x7F]/) + # Simple percent encoding for now + encode_utf8_component(part) + else + part + end + end + encoded_parts.join('.') + else + host + end + rescue StandardError + # Fallback to percent encoding + encode_utf8_component(host) + end + + if path + "#{encoded_host}/#{encode_utf8_component(path)}" + else + encoded_host + end + end + + # Validate if a URL is structurally valid for our purposes + # @param url [String] The URL to validate + # @return [Boolean] Whether the URL is valid + def valid_utf8_url?(url) + return false if url.blank? + + uri = safe_parse_uri(url) + return false unless uri + + # Check if it has a valid scheme + return false unless uri.scheme.present? + + # For our metrics, we accept http, https, tel, mailto + allowed_schemes = %w[http https tel mailto] + allowed_schemes.include?(uri.scheme.downcase) + end + end + end +end diff --git a/app/policies/better_together/event_policy.rb b/app/policies/better_together/event_policy.rb index 9af480fd0..3a10ba901 100644 --- a/app/policies/better_together/event_policy.rb +++ b/app/policies/better_together/event_policy.rb @@ -57,7 +57,7 @@ def event_host_member? class Scope < ApplicationPolicy::Scope def resolve scope.with_attached_cover_image - .includes(:string_translations, :text_translations, :location, :event_hosts, categorizations: { + .includes(:string_translations, :location, :event_hosts, categorizations: { category: %i[ string_translations cover_image_attachment cover_image_blob ] diff --git a/app/policies/better_together/platform_invitation_policy.rb b/app/policies/better_together/platform_invitation_policy.rb index e472cae8d..f135a0f5e 100644 --- a/app/policies/better_together/platform_invitation_policy.rb +++ b/app/policies/better_together/platform_invitation_policy.rb @@ -11,18 +11,17 @@ def create? end def destroy? - user.present? && record.status_pending? && permitted_to?('manage_platform') + user.present? && record.status_pending? && (record.inviter.id == agent.id || permitted_to?('manage_platform')) end def resend? - user.present? && record.status_pending? && permitted_to?('manage_platform') + user.present? && record.status_pending? && (record.inviter.id == agent.id || permitted_to?('manage_platform')) end class Scope < Scope # rubocop:todo Style/Documentation def resolve - results = scope.order(:last_sent) - - results = results.where(inviter: agent) unless permitted_to?('manage_platform') + results = scope + results = scope.where(inviter: agent) unless permitted_to?('manage_platform') results end diff --git a/app/views/better_together/pages/_page_row.html.erb b/app/views/better_together/pages/_page_row.html.erb index ff54bdb67..4ec1ea653 100644 --- a/app/views/better_together/pages/_page_row.html.erb +++ b/app/views/better_together/pages/_page_row.html.erb @@ -1,11 +1,11 @@ - <%= link_to page.title, render_page_path(page), target: "_blank", rel: "noopener" %> + <%= link_to page.title, render_page_path(page.slug), target: "_blank", rel: "noopener" %> <%= page.slug %> <%= link_to page.url, page.url, target: "_blank", rel: "noopener" %> <%= page.published? ? t('globals.published') : t('globals.draft') %> <% if policy(page).show? %> - <%= link_to render_page_path(page), class: 'btn btn-outline-info btn-sm me-1', 'aria-label' => 'Show Page' do %> + <%= link_to render_page_path(page.slug), class: 'btn btn-outline-info btn-sm me-1', 'aria-label' => 'Show Page' do %> <%= t('globals.show') %> <% end %> <% end %> diff --git a/app/views/better_together/pages/index.html.erb b/app/views/better_together/pages/index.html.erb index 08ab43bca..bcd4c9ef0 100644 --- a/app/views/better_together/pages/index.html.erb +++ b/app/views/better_together/pages/index.html.erb @@ -24,6 +24,8 @@ <% end %> + <%= paginate @pages %> +
@@ -35,11 +37,101 @@ + + + + + + + - <%= render partial: 'better_together/pages/page_row', collection: @pages, as: :page %> + <% cache pages_cache_key(@pages) do %> + <%= render partial: 'better_together/pages/page_row', collection: @pages, as: :page %> + <% end %>
<%= resource_class.human_attribute_name(:status) %> <%= t('globals.actions') %>
+ + <%= form_with url: pages_path(locale: I18n.locale), method: :get, local: true, class: 'd-flex', + data: { + controller: 'better-together--auto-submit', + auto_submit_delay_value: 500 + } do |form| %> +
+ <%= form.text_field :title_filter, + value: current_title_filter, + class: 'form-control form-control-sm', + placeholder: t('.search_by_title'), + 'aria-label' => t('.search_by_title') %> + <% if params[:title_filter].present? %> + <%= link_to pages_path( + slug_filter: params[:slug_filter], + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'input-group-text btn btn-outline-secondary btn-sm', + title: t('.clear_filter'), + 'aria-label' => t('.clear_filter') do %> + + <% end %> + <% end %> +
+ + + <% if params[:slug_filter].present? %> + <%= form.hidden_field :slug_filter, value: params[:slug_filter] %> + <% end %> + <% if params[:sort_by].present? %> + <%= form.hidden_field :sort_by, value: params[:sort_by] %> + <%= form.hidden_field :sort_direction, value: params[:sort_direction] %> + <% end %> + <% if params[:page].present? %> + <%= form.hidden_field :page, value: params[:page] %> + <% end %> + <% end %> +
+ + <%= form_with url: pages_path(locale: I18n.locale), method: :get, local: true, class: 'd-flex', + data: { + controller: 'better-together--auto-submit', + auto_submit_delay_value: 500 + } do |form| %> +
+ <%= form.text_field :slug_filter, + value: current_slug_filter, + class: 'form-control form-control-sm', + placeholder: t('.search_by_slug'), + 'aria-label' => t('.search_by_slug') %> + <% if params[:slug_filter].present? %> + <%= link_to pages_path( + title_filter: params[:title_filter], + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'input-group-text btn btn-outline-secondary btn-sm', + title: t('.clear_filter'), + 'aria-label' => t('.clear_filter') do %> + + <% end %> + <% end %> +
+ + + <% if params[:title_filter].present? %> + <%= form.hidden_field :title_filter, value: params[:title_filter] %> + <% end %> + <% if params[:sort_by].present? %> + <%= form.hidden_field :sort_by, value: params[:sort_by] %> + <%= form.hidden_field :sort_direction, value: params[:sort_direction] %> + <% end %> + <% if params[:page].present? %> + <%= form.hidden_field :page, value: params[:page] %> + <% end %> + <% end %> +
+ + + <%= paginate @pages %> diff --git a/app/views/better_together/pages/show.html.erb b/app/views/better_together/pages/show.html.erb index 188764ca6..700393180 100644 --- a/app/views/better_together/pages/show.html.erb +++ b/app/views/better_together/pages/show.html.erb @@ -49,23 +49,28 @@ <% end %> <% end %> -<% content_for :page_content do %> - <%= render @content_blocks if @content_blocks.size > 0 %> -<% end %> + +<% cache page_show_cache_key(@page) do %> + <% content_for :page_content do %> + <%= render @content_blocks if @content_blocks.size > 0 %> + <% end %> -<%= render layout: @layout do %> - <% if @page.template.present? && @content_blocks.size.zero? %> - <%= render template: @page.template %> - <% else %> - <%= render @page.hero_block if @page.hero_block %> - <% if @page.sidebar_nav.present? %> - <%= render layout: 'better_together/pages/sidebar_layout', locals: { nav: @page.sidebar_nav, current_page: @page } do %> + <%= render layout: @layout do %> + <% if @page.template.present? && @content_blocks.size.zero? %> + <%= render template: @page.template %> + <% else %> + <% cache ['page-hero', @page.id, @page.hero_block&.updated_at, I18n.locale] do %> + <%= render @page.hero_block if @page.hero_block %> + <% end %> + <% if @page.sidebar_nav.present? %> + <%= render layout: 'better_together/pages/sidebar_layout', locals: { nav: @page.sidebar_nav, current_page: @page } do %> + <%= yield :page_content %> + <% end %> + <% else %> <%= yield :page_content %> <% end %> - <% else %> - <%= yield :page_content %> - <% end %> - <%= share_buttons(shareable: @page) if @page.privacy_public? %> + <%= share_buttons(shareable: @page) if @page.privacy_public? %> + <% end %> <% end %> <% end %> diff --git a/app/views/better_together/person_platform_memberships/_person_platform_membership_member.html.erb b/app/views/better_together/person_platform_memberships/_person_platform_membership_member.html.erb index 86d1f0da2..40d9e1f03 100644 --- a/app/views/better_together/person_platform_memberships/_person_platform_membership_member.html.erb +++ b/app/views/better_together/person_platform_memberships/_person_platform_membership_member.html.erb @@ -5,12 +5,12 @@
<% if policy(person_platform_membership.member).show? %> - <%= link_to person_platform_membership.member, person_platform_membership.member, class: 'text-decoration-none' %> + <%= link_to person_platform_membership.member.name, person_platform_membership.member, class: 'text-decoration-none' %> <% else %> - <%= person_platform_membership.member %> + <%= person_platform_membership.member.name %> <% end %>
-

<%= person_platform_membership.role %>

+

<%= person_platform_membership.role.name %>

<%= profile_image_tag(person_platform_membership.member, size: 150, class: 'card-image') %> diff --git a/app/views/better_together/platform_invitations/_filter_hidden_fields.html.erb b/app/views/better_together/platform_invitations/_filter_hidden_fields.html.erb new file mode 100644 index 000000000..1d133eb5c --- /dev/null +++ b/app/views/better_together/platform_invitations/_filter_hidden_fields.html.erb @@ -0,0 +1,57 @@ +<% +# Partial to preserve filter parameters across form submissions +# Params: +# - form: the form builder +# - exclude: array of filter types to exclude (symbols) + +exclude_fields = local_assigns[:exclude] || [] +%> + +<% unless exclude_fields.include?(:search) %> + <% if current_search_filter_for_invitations.present? %> + <%= form.hidden_field 'filters[search]', value: current_search_filter_for_invitations %> + <% end %> +<% end %> + +<% unless exclude_fields.include?(:status) %> + <% if current_status_filter_for_invitations.present? %> + <%= form.hidden_field 'filters[status]', value: current_status_filter_for_invitations %> + <% end %> +<% end %> + +<% unless exclude_fields.include?(:valid_from) %> + <% if current_valid_from_filter_for_invitations.any? %> + <% current_valid_from_filter_for_invitations.each do |key, value| %> + <%= form.hidden_field "filters[valid_from][#{key}]", value: value %> + <% end %> + <% end %> +<% end %> + +<% unless exclude_fields.include?(:valid_until) %> + <% if current_valid_until_filter_for_invitations.any? %> + <% current_valid_until_filter_for_invitations.each do |key, value| %> + <%= form.hidden_field "filters[valid_until][#{key}]", value: value %> + <% end %> + <% end %> +<% end %> + +<% unless exclude_fields.include?(:accepted_at) %> + <% if current_accepted_at_filter_for_invitations.any? %> + <% current_accepted_at_filter_for_invitations.each do |key, value| %> + <%= form.hidden_field "filters[accepted_at][#{key}]", value: value %> + <% end %> + <% end %> +<% end %> + +<% unless exclude_fields.include?(:last_sent) %> + <% if current_last_sent_filter_for_invitations.any? %> + <% current_last_sent_filter_for_invitations.each do |key, value| %> + <%= form.hidden_field "filters[last_sent][#{key}]", value: value %> + <% end %> + <% end %> +<% end %> + + +<%= form.hidden_field :sort_by, value: params[:sort_by] if params[:sort_by].present? %> +<%= form.hidden_field :sort_direction, value: params[:sort_direction] if params[:sort_direction].present? %> +<%= form.hidden_field :page, value: params[:page] if params[:page].present? %> \ No newline at end of file diff --git a/app/views/better_together/platform_invitations/_platform_invitation.html.erb b/app/views/better_together/platform_invitations/_platform_invitation.html.erb index a635cec93..4cdea5d75 100644 --- a/app/views/better_together/platform_invitations/_platform_invitation.html.erb +++ b/app/views/better_together/platform_invitations/_platform_invitation.html.erb @@ -24,41 +24,43 @@ <% end %>
- - <%= platform_invitation.class.model_name.human %> - - - <%= platform_invitation.session_duration_mins %> - - - <%= platform_invitation.invitee_email %> - - - <% if platform_invitation.invitee %> - <%= link_to platform_invitation.invitee.name, platform_invitation.invitee, class: "text-decoration-none" %> - <% else %> - <%= t('globals.no_invitee') %> - <% end %> - - - <%= link_to platform_invitation.inviter.name, platform_invitation.inviter, class: "text-decoration-none" %> - - - <%= platform_invitation.status %> - - - <%= l(platform_invitation.valid_from, format: :short) %> - - - <%= l(platform_invitation.valid_until, format: :short) if platform_invitation.valid_until %> - - - <%= l(platform_invitation.accepted_at, format: :short) if platform_invitation.accepted_at %> - - - <%= l(platform_invitation.last_sent, format: :short) if platform_invitation.last_sent %> - - - <%= time_ago_in_words(platform_invitation.created_at) %> ago - + <% cache(['table-row', platform_invitation.cache_key_with_version]) do %> + + <%= platform_invitation.class.model_name.human %> + + + <%= platform_invitation.session_duration_mins %> + + + <%= platform_invitation.invitee_email %> + + + <% if platform_invitation.invitee_id.present? %> + <%= link_to platform_invitation.invitee.name, platform_invitation.invitee, class: "text-decoration-none" %> + <% else %> + <%= t('globals.no_invitee') %> + <% end %> + + + <%= link_to platform_invitation.inviter.name, platform_invitation.inviter, class: "text-decoration-none" %> + + + <%= platform_invitation.status %> + + + <%= l(platform_invitation.valid_from, format: :short) %> + + + <%= l(platform_invitation.valid_until, format: :short) if platform_invitation.valid_until %> + + + <%= l(platform_invitation.accepted_at, format: :short) if platform_invitation.accepted_at %> + + + <%= l(platform_invitation.last_sent, format: :short) if platform_invitation.last_sent %> + + + <%= time_ago_in_words(platform_invitation.created_at) %> ago + + <% end %> diff --git a/app/views/better_together/platform_invitations/index.html.erb b/app/views/better_together/platform_invitations/index.html.erb new file mode 100644 index 000000000..8590a3940 --- /dev/null +++ b/app/views/better_together/platform_invitations/index.html.erb @@ -0,0 +1,409 @@ + + +<%= turbo_frame_tag "platform_invitations_content" do %> +
+
+ + + + +
+
+ +
+
+ <%= turbo_frame_tag 'platform_invitations_table' do %> + <%= form_with url: platform_platform_invitations_path(@platform), method: :get, local: true, + data: { + turbo_frame: "platform_invitations_content", + controller: 'better-together--auto-submit', + auto_submit_delay_value: 500 + } do |form| %> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <%= (render partial: 'better_together/platform_invitations/platform_invitation', collection: @platform_invitations) || (render partial: 'better_together/platform_invitations/empty') %> + +
<%= t('globals.actions') %><%= BetterTogether::PlatformInvitation.human_attribute_name(:type) %><%= BetterTogether::PlatformInvitation.human_attribute_name(:session_duration_mins) %><%= sortable_column_header_for_invitations('invitee_email', BetterTogether::PlatformInvitation.human_attribute_name(:invitee_email), @platform) %><%= BetterTogether::PlatformInvitation.human_attribute_name(:invitee) %><%= BetterTogether::PlatformInvitation.human_attribute_name(:inviter) %><%= sortable_column_header_for_invitations('status', BetterTogether::PlatformInvitation.human_attribute_name(:status), @platform) %><%= sortable_column_header_for_invitations('valid_from', BetterTogether::PlatformInvitation.human_attribute_name(:valid_from), @platform) %><%= sortable_column_header_for_invitations('valid_until', BetterTogether::PlatformInvitation.human_attribute_name(:valid_until), @platform) %><%= sortable_column_header_for_invitations('accepted_at', BetterTogether::PlatformInvitation.human_attribute_name(:accepted_at), @platform) %><%= sortable_column_header_for_invitations('last_sent', BetterTogether::PlatformInvitation.human_attribute_name(:last_sent), @platform) %><%= sortable_column_header_for_invitations('created_at', BetterTogether::PlatformInvitation.human_attribute_name(:created_at), @platform) %>
+
+ <%= form.text_field 'filters[search]', + value: current_search_filter_for_invitations, + class: 'form-control form-control-sm', + placeholder: t('better_together.platform_invitations.search_by_email'), + 'aria-label' => t('better_together.platform_invitations.search_by_email') %> + <% if current_search_filter_for_invitations.present? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { status: current_status_filter_for_invitations }.compact, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'input-group-text btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+
+ <%= form.select 'filters[status]', + options_for_select([ + [t('globals.all'), ''], + [t('better_together.platform_invitations.status.pending'), 'pending'], + [t('better_together.platform_invitations.status.accepted'), 'accepted'], + [t('better_together.platform_invitations.status.expired'), 'expired'] + ], current_status_filter_for_invitations), + {}, { + class: "form-select form-select-sm", + 'aria-label' => t('better_together.platform_invitations.filter_by_status'), + data: { action: 'change->better-together--auto-submit#submit' } + } %> + <% if current_status_filter_for_invitations.present? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { search: current_search_filter_for_invitations }.compact, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'input-group-text btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+
+
+ <%= form.date_field 'filters[valid_from][from]', + value: current_valid_from_filter_for_invitations[:from], + class: 'form-control form-control-sm', + placeholder: t('globals.from'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:valid_from)} #{t('globals.from')}" %> +
+
+ <%= form.date_field 'filters[valid_from][to]', + value: current_valid_from_filter_for_invitations[:to], + class: 'form-control form-control-sm', + placeholder: t('globals.to'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:valid_from)} #{t('globals.to')}" %> +
+ <% if current_valid_from_filter_for_invitations.any? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { + search: current_search_filter_for_invitations, + status: current_status_filter_for_invitations, + valid_until: current_valid_until_filter_for_invitations, + accepted_at: current_accepted_at_filter_for_invitations, + last_sent: current_last_sent_filter_for_invitations + }.compact_blank, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+
+
+ <%= form.date_field 'filters[valid_until][from]', + value: current_valid_until_filter_for_invitations[:from], + class: 'form-control form-control-sm', + placeholder: t('globals.from'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:valid_until)} #{t('globals.from')}" %> +
+
+ <%= form.date_field 'filters[valid_until][to]', + value: current_valid_until_filter_for_invitations[:to], + class: 'form-control form-control-sm', + placeholder: t('globals.to'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:valid_until)} #{t('globals.to')}" %> +
+ <% if current_valid_until_filter_for_invitations.any? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { + search: current_search_filter_for_invitations, + status: current_status_filter_for_invitations, + valid_from: current_valid_from_filter_for_invitations, + accepted_at: current_accepted_at_filter_for_invitations, + last_sent: current_last_sent_filter_for_invitations + }.compact_blank, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+
+
+ <%= form.date_field 'filters[accepted_at][from]', + value: current_accepted_at_filter_for_invitations[:from], + class: 'form-control form-control-sm', + placeholder: t('globals.from'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:accepted_at)} #{t('globals.from')}" %> +
+
+ <%= form.date_field 'filters[accepted_at][to]', + value: current_accepted_at_filter_for_invitations[:to], + class: 'form-control form-control-sm', + placeholder: t('globals.to'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:accepted_at)} #{t('globals.to')}" %> +
+ <% if current_accepted_at_filter_for_invitations.any? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { + search: current_search_filter_for_invitations, + status: current_status_filter_for_invitations, + valid_from: current_valid_from_filter_for_invitations, + valid_until: current_valid_until_filter_for_invitations, + last_sent: current_last_sent_filter_for_invitations + }.compact_blank, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+
+
+ <%= form.date_field 'filters[last_sent][from]', + value: current_last_sent_filter_for_invitations[:from], + class: 'form-control form-control-sm', + placeholder: t('globals.from'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:last_sent)} #{t('globals.from')}" %> +
+
+ <%= form.date_field 'filters[last_sent][to]', + value: current_last_sent_filter_for_invitations[:to], + class: 'form-control form-control-sm', + placeholder: t('globals.to'), + 'aria-label' => "#{BetterTogether::PlatformInvitation.human_attribute_name(:last_sent)} #{t('globals.to')}" %> +
+ <% if current_last_sent_filter_for_invitations.any? %> + <%= link_to platform_platform_invitations_path(@platform, + filters: { + search: current_search_filter_for_invitations, + status: current_status_filter_for_invitations, + valid_from: current_valid_from_filter_for_invitations, + valid_until: current_valid_until_filter_for_invitations, + accepted_at: current_accepted_at_filter_for_invitations + }.compact_blank, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction], + page: params[:page] + ), class: 'btn btn-outline-secondary btn-sm', + title: t('globals.clear_filter'), + 'aria-label' => t('globals.clear_filter'), + data: { turbo_frame: "platform_invitations_content" } do %> + + <% end %> + <% end %> +
+
+ + + <%= render partial: 'better_together/platform_invitations/filter_hidden_fields', locals: { form: form, exclude: [] } %> + <% end %> + <% end %> +
+
+ + + <% if @platform_invitations.respond_to?(:current_page) %> +
+
+

+ <%= t('globals.pagination.showing') %> + <%= @platform_invitations.offset_value + 1 %> + <%= t('globals.pagination.to') %> + <%= [@platform_invitations.offset_value + @platform_invitations.limit_value, @platform_invitations.total_count].min %> + <%= t('globals.pagination.of') %> + <%= @platform_invitations.total_count %> + <%= t('better_together.platform_invitations.invitations').downcase %> +

+
+
+
+ <%= paginate @platform_invitations, + remote: true, + data: { turbo_frame: "platform_invitations_content" } %> +
+
+
+ <% end %> +<% end %> + + \ No newline at end of file diff --git a/app/views/better_together/platforms/show.html.erb b/app/views/better_together/platforms/show.html.erb index 0c7705e82..0475d2716 100644 --- a/app/views/better_together/platforms/show.html.erb +++ b/app/views/better_together/platforms/show.html.erb @@ -89,133 +89,21 @@ <% if policy(BetterTogether::PlatformInvitation).index? %> - <% end %> @@ -225,35 +113,6 @@