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}| <%= 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 %>
+ |
+ + | + | + |
<%= person_platform_membership.role %>
+<%= person_platform_membership.role.name %>
| <%= 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 %>
+ |
+
+ + |
+ <%= 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 %> +
+| Actions | -Type | -Session Duration | -Invitee | -Inviter | -Status | -Valid From | -Valid Until | -Accepted At | -Last Sent | -Created | -
|---|