diff --git a/app/controllers/community_news_controller.rb b/app/controllers/community_news_controller.rb index ac5792d9c..11012c7b0 100644 --- a/app/controllers/community_news_controller.rb +++ b/app/controllers/community_news_controller.rb @@ -71,7 +71,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Community news create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -95,7 +95,7 @@ def update assign_associations(@community_news) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Community news update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 20bfb6a9a..144178bd7 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -56,27 +56,15 @@ def create if @event_registration.save respond_to do |format| format.html { - if current_user.super_user? - return_to = params.dig(:event_registration, :event_id).present? ? "manage" : "ticket" - redirect_to confirm_event_registration_path(@event_registration, return_to: return_to) - elsif params.dig(:event_registration, :event_id).present? - redirect_to manage_event_path(@event_registration.event), - notice: "Registration created." - else - redirect_to registration_ticket_path(@event_registration.slug), - notice: "Registration created." - end + redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) } end else respond_to do |format| format.html { - if @event_registration.event_id.present? - redirect_to manage_event_path(@event_registration.event), - alert: @event_registration.errors.full_messages.to_sentence - else - redirect_to event_registrations_path, - alert: @event_registration.errors.full_messages.to_sentence + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), alert: @event_registration.errors.full_messages.to_sentence + else redirect_to event_registrations_path, alert: @event_registration.errors.full_messages.to_sentence end } end @@ -93,10 +81,10 @@ def update respond_to do |format| format.turbo_stream format.html { - if params[:return_to] == "manage" - redirect_to manage_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - else - redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other + else redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other end } end @@ -133,22 +121,26 @@ def process_confirm current_user: current_user ) - if params[:return_to] == "manage" - redirect_to manage_event_path(@event_registration.event), notice: result.summary - else - redirect_to registration_ticket_path(@event_registration.slug), notice: result.summary + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), notice: result.summary + when "index" then redirect_to event_registrations_path, notice: result.summary + else redirect_to registration_ticket_path(@event_registration.slug), notice: result.summary end end def destroy authorize! @event_registration + event = @event_registration.event if @event_registration.destroy flash[:notice] = "Registration deleted." - else flash[:alert] = @event_registration.errors.full_messages.to_sentence end - redirect_to event_registrations_path + + case params[:return_to] + when "manage" then redirect_to manage_event_path(event) + else redirect_to event_registrations_path + end end # Optional hooks for setting variables for forms or index @@ -167,14 +159,15 @@ def set_form_variables private def set_event_registration - @event_registration = EventRegistration.includes(:registrant, { event: [ :location, :event_forms ] }, comments: [ :created_by, :updated_by ]).find(params[:id]) + @event_registration = EventRegistration.includes({ registrant: { affiliations: :organization } }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end # Strong parameters def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, - :scholarship_recipient, :scholarship_tasks_completed, + :scholarship_requested, :scholarship_recipient, :scholarship_tasks_completed, + organization_ids: [], comments_attributes: [ :id, :body, :_destroy ] ) end diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index ade7bfcec..d9dd62012 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -142,7 +142,7 @@ def validate_required_fields(form_params) if field.number_integer? && value.to_s !~ /\A\d+\z/ errors[field.id] = "must be a whole number" - elsif field.field_key&.match?(/email(?!_type)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ + elsif field.field_key&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ errors[field.id] = "must be a valid email address" end end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 4188bc8dc..c043b3f52 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -39,7 +39,7 @@ def manage authorize! @event, to: :manage? @event = @event.decorate scope = @event.event_registrations - .includes(:payments, :comments, registrant: [ :user, :contact_methods, { affiliations: :organization }, { avatar_attachment: :blob } ]) + .includes(:payments, :comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present? @@ -75,7 +75,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Event create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -105,7 +105,7 @@ def update else raise ActiveRecord::Rollback end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Event update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb new file mode 100644 index 000000000..7de7111d3 --- /dev/null +++ b/app/controllers/forms_controller.rb @@ -0,0 +1,141 @@ +class FormsController < ApplicationController + before_action :set_form, only: %i[show edit update destroy question_library add_questions] + + def index + authorize! + @forms = authorized_scope(Form.all) + .includes(:form_fields, :events) + .order(:name) + .paginate(page: params[:page], per_page: 25) + end + + def show + authorize! @form + @form_fields_by_group = grouped_fields + @linked_events = @form.events.order(start_date: :desc) + end + + def new + authorize! Form.new + @builders = builder_options + end + + def create + authorize! Form.new + + @form = case params[:builder_type] + when "short_registration" + ShortEventRegistrationFormBuilder.build_standalone! + when "extended_registration" + ExtendedEventRegistrationFormBuilder.build_standalone! + when "scholarship_application" + ScholarshipApplicationFormBuilder.build_standalone! + when "generic" + Form.create!(name: params[:form_name].presence || "New Form") + else + Form.create!(name: "New Form") + end + + redirect_to edit_form_path(@form), notice: "Form was successfully created. Customize it below." + end + + def edit + authorize! @form + set_form_variables + end + + def update + authorize! @form + if @form.update(form_params) + redirect_to @form, notice: "Form was successfully updated.", status: :see_other + else + set_form_variables + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize! @form + @form.destroy! + redirect_to forms_path, notice: "Form was successfully destroyed." + end + + def question_library + authorize! @form + existing_keys = @form.form_fields.reorder(position: :asc).pluck(:field_key).compact + scope = FormField.unscoped + .where.not(form_id: @form.id) + .where.not(field_key: [ nil, "" ]) + scope = scope.where.not(field_key: existing_keys) if existing_keys.any? + @available_fields = scope.order(:field_group, :position).group_by(&:field_group) + render layout: false + end + + def add_questions + authorize! @form + source_field_ids = Array(params[:source_field_ids]).map(&:to_i) + max_position = @form.form_fields.reorder(position: :asc).maximum(:position) || 0 + + FormField.unscoped.where(id: source_field_ids).find_each do |source| + max_position += 1 + new_field = @form.form_fields.create!( + question: source.question, + answer_type: source.answer_type, + answer_datatype: source.answer_datatype, + status: source.status, + position: max_position, + is_required: source.is_required, + instructional_hint: source.instructional_hint, + field_key: source.field_key, + field_group: source.field_group + ) + + source.form_field_answer_options.each do |ffao| + new_field.form_field_answer_options.create!(answer_option: ffao.answer_option) + end + end + + redirect_to edit_form_path(@form), notice: "Questions added successfully." + end + + private + + def set_form + @form = Form.find(params[:id]) + end + + def set_form_variables + @form_fields_by_group = grouped_fields + @field_groups = @form.form_fields.reorder(position: :asc).pluck(:field_group).compact.uniq + @answer_type_options = FormField.answer_types.keys.map { |k| [ k.humanize, k ] } + end + + def grouped_fields + @form.form_fields.reorder(position: :asc).group_by(&:field_group) + end + + def builder_options + [ + { key: "short_registration", name: ShortEventRegistrationFormBuilder::FORM_NAME, + description: "Contact, consent, qualitative, and scholarship fields" }, + { key: "extended_registration", name: ExtendedEventRegistrationFormBuilder::FORM_NAME, + description: "Contact, background, professional, qualitative, scholarship, and payment fields" }, + { key: "scholarship_application", name: ScholarshipApplicationFormBuilder::FORM_NAME, + description: "Scholarship-specific fields" }, + { key: "generic", name: "Generic Form", + description: "Start with a blank form and add fields manually" } + ] + end + + def form_params + params.require(:form).permit( + :name, + :scholarship_application, + form_fields_attributes: [ + :id, :question, :answer_type, :answer_datatype, + :status, :position, :is_required, :instructional_hint, + :field_key, :field_group, :_destroy + ] + ) + end +end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index c12c50b0e..a519531a7 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -73,7 +73,7 @@ def create assign_associations(@resource) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Resource create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -99,7 +99,7 @@ def update assign_associations(@resource) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Resource update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index f483efcfb..bc1fdb081 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -75,7 +75,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Story create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -101,7 +101,7 @@ def update end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Story update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index e7e287759..5e1bfe803 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -59,7 +59,7 @@ def create notification_type: 0) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "StoryIdea create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -88,7 +88,7 @@ def update assign_associations(@story_idea) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "StoryIdea update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb index aca8c3f7d..17d5faf23 100644 --- a/app/controllers/tutorials_controller.rb +++ b/app/controllers/tutorials_controller.rb @@ -51,7 +51,7 @@ def create assign_associations(@tutorial) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Tutorial create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -75,7 +75,7 @@ def update assign_associations(@tutorial) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Tutorial update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 78ed72cba..966402b7f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -151,6 +151,8 @@ def destroy authorize! @user @user.destroy! redirect_to users_path, notice: "User was successfully destroyed." + rescue ActiveRecord::InvalidForeignKey + redirect_to @user, alert: "Unable to delete this user because they have associated records that cannot be removed." end # --------------------------------------------------------- diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index 2b1eda2c6..681e2fd0a 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -43,6 +43,11 @@ def create set_form_variables render :new, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopIdea create failed: #{e.class} - #{e.message}" + @workshop_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :new, status: :unprocessable_content end def edit @@ -59,6 +64,11 @@ def update set_form_variables render :edit, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopIdea update failed: #{e.class} - #{e.message}" + @workshop_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :edit, status: :unprocessable_content end def destroy diff --git a/app/controllers/workshop_variation_ideas_controller.rb b/app/controllers/workshop_variation_ideas_controller.rb index 773cc8cda..b3c305cf1 100644 --- a/app/controllers/workshop_variation_ideas_controller.rb +++ b/app/controllers/workshop_variation_ideas_controller.rb @@ -70,6 +70,11 @@ def create set_form_variables render :new, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopVariationIdea create failed: #{e.class} - #{e.message}" + @workshop_variation_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :new, status: :unprocessable_content end def update @@ -80,6 +85,11 @@ def update set_form_variables render :edit, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopVariationIdea update failed: #{e.class} - #{e.message}" + @workshop_variation_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :edit, status: :unprocessable_content end def destroy diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 94fb27403..3c55fa029 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -35,7 +35,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("creation", e) raise ActiveRecord::Rollback end diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index d132fc940..03ac9f226 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -100,7 +100,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("creation", e) raise ActiveRecord::Rollback end @@ -158,7 +158,7 @@ def update assign_associations(@workshop) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("update", e) raise ActiveRecord::Rollback end diff --git a/app/decorators/resource_decorator.rb b/app/decorators/resource_decorator.rb index f0c1c5f54..2d37dca29 100644 --- a/app/decorators/resource_decorator.rb +++ b/app/decorators/resource_decorator.rb @@ -1,6 +1,7 @@ class ResourceDecorator < ApplicationDecorator def detail(length: nil) - length ? body&.truncate(length) : body # TODO - rename field + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end def default_display_image @@ -20,7 +21,7 @@ def truncated_title end def truncated_text(ln = 100) - h.truncate(html.text.gsub(/(<[^>]+>)/, ""), length: ln) + h.truncate(rhino_body.to_plain_text, length: ln) end def display_title @@ -28,7 +29,7 @@ def display_title end def flex_text - h.truncate(html.text, length: 200) + h.truncate(rhino_body.to_plain_text, length: 200) end def breadcrumbs @@ -44,7 +45,7 @@ def display_date end def display_text - "
#{text}
".html_safe + "
#{rhino_body}
".html_safe end def card_class @@ -58,7 +59,7 @@ def toolkit_and_form? private def html - Nokogiri::HTML(text) + Nokogiri::HTML(rhino_body.to_s) end def type_link diff --git a/app/decorators/story_decorator.rb b/app/decorators/story_decorator.rb index d89f6ed1d..1f40ab565 100644 --- a/app/decorators/story_decorator.rb +++ b/app/decorators/story_decorator.rb @@ -2,7 +2,8 @@ class StoryDecorator < ApplicationDecorator include ::Linkable def detail(length: 50) - body&.truncate(length) + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end def external_url diff --git a/app/decorators/tutorial_decorator.rb b/app/decorators/tutorial_decorator.rb index 6b8b0c10d..36763f93c 100644 --- a/app/decorators/tutorial_decorator.rb +++ b/app/decorators/tutorial_decorator.rb @@ -2,10 +2,11 @@ class TutorialDecorator < ApplicationDecorator delegate_all def display_text - "
#{body}
".html_safe + "
#{rhino_body}
".html_safe end def detail(length: nil) - length ? body&.truncate(length) : body + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end end diff --git a/app/decorators/workshop_decorator.rb b/app/decorators/workshop_decorator.rb index 3803e627b..fb3a5ac10 100644 --- a/app/decorators/workshop_decorator.rb +++ b/app/decorators/workshop_decorator.rb @@ -99,9 +99,10 @@ def formatted_objective(length: 100) end def spanish_field_values - display_spanish_fields.map do |field| - workshop.send(field) unless workshop.send(field).blank? - end.compact + display_spanish_fields.filter_map do |field| + value = workshop.send(:"rhino_#{field}") + value unless value.blank? + end end @@ -154,10 +155,10 @@ def labels_spanish private def html_content - Nokogiri::HTML("#{objective} #{materials} #{timeframe} #{setup} #{workshop.introduction}") + Nokogiri::HTML("#{rhino_objective} #{rhino_materials} #{timeframe} #{rhino_setup} #{rhino_introduction}") end def html_objective - Nokogiri::HTML(objective) + Nokogiri::HTML(rhino_objective.to_s) end end diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index b1ca21084..269a529d1 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -45,9 +45,15 @@ application.register("inactive-toggle", InactiveToggleController) import OptimisticBookmarkController from "./optimistic_bookmark_controller" application.register("optimistic-bookmark", OptimisticBookmarkController) +import OrgToggleController from "./org_toggle_controller" +application.register("org-toggle", OrgToggleController) + import PaginatedFieldsController from "./paginated_fields_controller" application.register("paginated-fields", PaginatedFieldsController) +import QuestionLibraryController from "./question_library_controller" +application.register("question-library", QuestionLibraryController) + import PasswordToggleController from "./password_toggle_controller" application.register("password-toggle", PasswordToggleController) diff --git a/app/frontend/javascript/controllers/org_toggle_controller.js b/app/frontend/javascript/controllers/org_toggle_controller.js new file mode 100644 index 000000000..dec1cc027 --- /dev/null +++ b/app/frontend/javascript/controllers/org_toggle_controller.js @@ -0,0 +1,50 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="org-toggle" +// Allows adding an org from the "current orgs" list to the +// "organizations at registration" chip list. + +export default class extends Controller { + static targets = ["chips"] + + add(event) { + const button = event.currentTarget + const orgId = button.dataset.orgId + const orgName = button.dataset.orgName + + // Check if already present + const existing = this.chipsTarget.querySelector(`input[value="${orgId}"]`) + if (existing) { + existing.checked = true + return + } + + const label = document.createElement("label") + label.className = "inline-flex items-center gap-1 px-4 py-1 rounded-full text-xs border cursor-pointer transition-colors bg-red-50 border-red-200 text-red-400 line-through has-[:checked]:bg-emerald-50 has-[:checked]:border-emerald-200 has-[:checked]:text-emerald-800 has-[:checked]:no-underline" + + const input = document.createElement("input") + input.type = "checkbox" + input.name = "event_registration[organization_ids][]" + input.value = orgId + input.checked = true + input.className = "sr-only" + + const nameSpan = document.createElement("span") + nameSpan.textContent = orgName + + const xSpan = document.createElement("span") + xSpan.className = "text-xs opacity-50" + xSpan.innerHTML = "×" + + label.appendChild(input) + label.appendChild(nameSpan) + label.appendChild(xSpan) + + this.chipsTarget.appendChild(label) + + // Show the section if it was hidden + this.chipsTarget.closest("[data-org-toggle-target='section']")?.classList.remove("hidden") + + button.remove() + } +} diff --git a/app/frontend/javascript/controllers/question_library_controller.js b/app/frontend/javascript/controllers/question_library_controller.js new file mode 100644 index 000000000..6306a7913 --- /dev/null +++ b/app/frontend/javascript/controllers/question_library_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus"; + +// Handles filtering/searching the question library picker on the forms edit page. +export default class extends Controller { + static targets = ["search", "list", "item"]; + + filter() { + const query = this.searchTarget.value.toLowerCase().trim(); + + this.itemTargets.forEach((item) => { + const text = item.dataset.questionText || ""; + item.style.display = text.includes(query) ? "" : "none"; + }); + } +} diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index 22ba7ffb3..76e651a0b 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -52,7 +52,8 @@ def reference_cards intensity: 100, title: "Sectors", params: { published: true }), - custom_card("Windows types", windows_types_path, icon: "🪟") + custom_card("Windows types", windows_types_path, icon: "🪟"), + model_card(:forms, icon: "📋", intensity: 100) ] end diff --git a/app/models/concerns/rich_text_searchable.rb b/app/models/concerns/rich_text_searchable.rb index 647a063b3..e44f8c9b6 100644 --- a/app/models/concerns/rich_text_searchable.rb +++ b/app/models/concerns/rich_text_searchable.rb @@ -11,7 +11,7 @@ def join_rich_texts join_condition = rich_texts[:record_id].eq(table[:id]) .and(rich_texts[:record_type].eq(name)) # polymorphic record_type = model name - joins(table.join(rich_texts, Arel::Nodes::InnerJoin).on(join_condition).join_sources) + joins(table.join(rich_texts, Arel::Nodes::OuterJoin).on(join_condition).join_sources) end end end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 5716d66f9..793c196a5 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -2,7 +2,9 @@ class EventRegistration < ApplicationRecord belongs_to :registrant, class_name: "Person" belongs_to :event has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy + has_many :event_registration_organizations, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :destroy + has_many :organizations, through: :event_registration_organizations has_many :payments, as: :payable before_destroy :create_refund_payments @@ -10,6 +12,7 @@ class EventRegistration < ApplicationRecord accepts_nested_attributes_for :comments, reject_if: proc { |attrs| attrs["body"].blank? } before_create :generate_slug + after_create :snapshot_registrant_organizations after_commit :send_cancellation_emails, if: :status_changed_to_cancelled? ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze @@ -127,6 +130,12 @@ def attendance_status_label private + def snapshot_registrant_organizations + registrant.affiliations.active.includes(:organization).find_each do |aff| + event_registration_organizations.create(organization: aff.organization) + end + end + def create_refund_payments paid_cents = payments.successful.sum(:amount_cents) return if paid_cents <= 0 diff --git a/app/models/event_registration_organization.rb b/app/models/event_registration_organization.rb new file mode 100644 index 000000000..c8a3b1d37 --- /dev/null +++ b/app/models/event_registration_organization.rb @@ -0,0 +1,6 @@ +class EventRegistrationOrganization < ApplicationRecord + belongs_to :event_registration + belongs_to :organization + + validates :organization_id, uniqueness: { scope: :event_registration_id } +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 1cbe9a8a4..11fdf835e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -7,6 +7,8 @@ class Organization < ApplicationRecord has_many :addresses, as: :addressable, dependent: :destroy has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :affiliations, dependent: :restrict_with_error + has_many :event_registration_organizations, dependent: :restrict_with_error + has_many :event_registrations, through: :event_registration_organizations has_many :people, through: :affiliations has_many :users, through: :people has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy diff --git a/app/models/report.rb b/app/models/report.rb index 71bf1c168..9129dfc03 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -3,7 +3,7 @@ class Report < ApplicationRecord belongs_to :created_by, class_name: "User" belongs_to :organization belongs_to :windows_type - belongs_to :workshop + belongs_to :workshop, optional: true has_one :form, as: :owner has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :destroy, autosave: false diff --git a/app/models/resource.rb b/app/models/resource.rb index 530eb2781..0981c9fe0 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -66,15 +66,14 @@ def self.mentionable_rich_text_fields allow_destroy: true, reject_if: proc { |resource| Resource.reject?(resource) } - # Search Cop — only attributes on resources table so MATCH() uses the FULLTEXT index (title, author, body). - # No join to action_text_rich_texts; that would require MATCH across two tables, which MySQL cannot index. include SearchCop search_scope :search do - attributes :title, :author, :body + attributes all: [ :title, :author ] + options :all, type: :text, default: true, default_operator: :or - # scope { join_rich_texts } - # attributes action_text_body: "action_text_rich_texts.plain_text_body" - # options :action_text_body, type: :text, default: true, default_operator: :or + scope { join_rich_texts } + attributes action_text_body: "action_text_rich_texts.plain_text_body" + options :action_text_body, type: :text, default: true, default_operator: :or end # Scopes diff --git a/app/models/story.rb b/app/models/story.rb index a0d34192e..cf5833992 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -43,7 +43,10 @@ class Story < ApplicationRecord # SearchCop include SearchCop search_scope :search do - attributes :title, :published, person_first: "people.first_name", person_last: "people.last_name" + attributes all: [ :title, :published ] + attributes :title, :published + attributes person_first: "people.first_name", person_last: "people.last_name" + options :all, type: :text, default: true, default_operator: :or scope { join_rich_texts.left_joins(created_by: :person) } attributes action_text_body: "action_text_rich_texts.plain_text_body" diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 6d48451a6..88936a372 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -24,14 +24,18 @@ class Tutorial < ApplicationRecord # SearchCop include SearchCop search_scope :search do - attributes :title, :body + attributes all: [ :title, :body ] + options :all, type: :text, default: true, default_operator: :or scope { join_rich_texts } attributes action_text_body: "action_text_rich_texts.plain_text_body" options :action_text_body, type: :text, default: true, default_operator: :or end - scope :body, ->(body) { where("body like ?", "%#{ body }%") } + scope :body, ->(body) { + left_joins(:rich_text_rhino_body) + .where("tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: "%#{body}%") + } scope :title, ->(title) { where("title like ?", "%#{ title }%") } scope :tutorial_name, ->(tutorial_name) { title(tutorial_name) } scope :with_sector_ids, ->(sector_hash) { @@ -51,9 +55,12 @@ class Tutorial < ApplicationRecord } scope :title_or_body, ->(term) { - pattern = "%#{term}%" - left_joins(:rich_text_rhino_body) - .where("tutorials.title LIKE :q OR tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: pattern) + scope = left_joins(:rich_text_rhino_body) + term.split.each do |word| + pattern = "%#{word}%" + scope = scope.where("tutorials.title LIKE :q OR tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: pattern) + end + scope } def self.search_by_params(params) diff --git a/app/models/user.rb b/app/models/user.rb index 2b5ee786a..e8f9bde52 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,7 +19,6 @@ class User < ApplicationRecord after_update :track_password_reset_sent before_destroy :track_account_deleted - before_destroy :reassign_reports_and_logs_to_orphaned_user # Associations belongs_to :person, optional: true @@ -171,6 +170,18 @@ def organization_workshop_logs(date, windows_type, organization_id) end end + def deletable? + !reports.exists? && + !workshop_logs.exists? && + !resources.exists? && + !workshops.exists? && + !stories_as_creator.exists? && + !story_ideas_as_creator.exists? && + !workshop_ideas_as_creator.exists? && + !workshop_variations_as_creator.exists? && + !workshop_variation_ideas_creator.exists? + end + def name person ? person.name : email end @@ -243,16 +254,6 @@ def person_id_must_be_present_if_previously_set errors.add(:person_id, "cannot be removed once set") end - def reassign_reports_and_logs_to_orphaned_user - orphaned_user = User.find_by(email: "orphaned_reports@awbw.org") - return unless orphaned_user - - # Reassign reports - reports.update_all(created_by_id: orphaned_user.id) - - # Reassign workshop_logs - workshop_logs.update_all(created_by_id: orphaned_user.id) - end def after_confirmation super diff --git a/app/models/workshop_log.rb b/app/models/workshop_log.rb index 30bf03133..2b31b7c07 100644 --- a/app/models/workshop_log.rb +++ b/app/models/workshop_log.rb @@ -4,6 +4,7 @@ class WorkshopLog < Report validates :children_ongoing, :teens_ongoing, :adults_ongoing, :children_first_time, :teens_first_time, :adults_first_time, numericality: { greater_than_or_equal_to: 0, only_integer: true } + validate :workshop_or_external_title_present # Callbacks after_save :update_owner_and_date @@ -137,6 +138,11 @@ def totals private + def workshop_or_external_title_present + return if workshop.present? || external_workshop_title.present? + errors.add(:base, "Please select a workshop or provide an external workshop title") + end + def update_workshop_log_count return unless owner new_led_count = owner.workshop_logs.size diff --git a/app/models/workshop_variation.rb b/app/models/workshop_variation.rb index 888fef63c..5cc80e187 100644 --- a/app/models/workshop_variation.rb +++ b/app/models/workshop_variation.rb @@ -1,9 +1,14 @@ class WorkshopVariation < ApplicationRecord include AuthorCreditable - include Publishable, Trendable + include Publishable, Trendable, RichTextSearchable include SearchCop search_scope :search do - attributes :name, :body + attributes all: [ :name ] + options :all, type: :text, default: true, default_operator: :or + + scope { join_rich_texts } + attributes action_text_body: "action_text_rich_texts.plain_text_body" + options :action_text_body, type: :text, default: true, default_operator: :or end def self.search_by_params(params) @@ -43,7 +48,7 @@ def self.search_by_params(params) # See Publishable, Trendable def description - body + rhino_body.to_plain_text end def title diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb new file mode 100644 index 000000000..875340ee7 --- /dev/null +++ b/app/policies/form_policy.rb @@ -0,0 +1,6 @@ +class FormPolicy < ApplicationPolicy + relation_scope do |relation| + next relation if admin? + relation.none + end +end diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 2ee027953..8adbbb3a8 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -10,11 +10,11 @@ def show? end def edit? - admin? || owner? + admin? end def update? - admin? || owner? + admin? end def destroy? diff --git a/app/services/base_registration_form_builder.rb b/app/services/base_registration_form_builder.rb new file mode 100644 index 000000000..64c2b9ad8 --- /dev/null +++ b/app/services/base_registration_form_builder.rb @@ -0,0 +1,94 @@ +class BaseRegistrationFormBuilder + protected + + def build_basic_contact_fields(form, position) + position = add_field(form, position, "First Name", :free_form_input_one_line, + key: "first_name", group: "contact", required: true) + position = add_field(form, position, "Last Name", :free_form_input_one_line, + key: "last_name", group: "contact", required: true) + position = add_field(form, position, "Email", :free_form_input_one_line, + key: "primary_email", group: "contact", required: true) + position = add_field(form, position, "Confirm Email", :free_form_input_one_line, + key: "confirm_email", group: "contact", required: true) + + position + end + + def build_scholarship_fields(form, position) + position = add_header(form, position, "Scholarship Application", group: "scholarship") + + position = add_field(form, position, + "I / my agency cannot afford the full training cost and need a scholarship to attend.", + :multiple_choice_checkbox, + key: "scholarship_eligibility", group: "scholarship", required: true, + options: [ "Yes" ]) + position = add_field(form, position, + "How will what you gain from this training directly impact the people you serve?", + :free_form_input_paragraph, + key: "impact_description", group: "scholarship", required: true, + hint: "Please describe in 3-5+ sentences.") + position = add_field(form, position, + "Please describe one way in which you plan to use art workshops and how you envision it will help.", + :free_form_input_paragraph, + key: "implementation_plan", group: "scholarship", required: true, + hint: "Please describe in 3-5+ sentences.") + position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, + key: "additional_comments", group: "scholarship", required: false) + + position + end + + def build_consent_fields(form, position) + position = add_header(form, position, "Consent", group: "consent") + position = add_field(form, position, + "I agree to receive email communications from A Window Between Worlds.", + :multiple_choice_radio, + key: "communication_consent", group: "consent", required: true, + hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \ + "including information about this event as well as upcoming events, training opportunities, resources, " \ + "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", + options: %w[Yes No]) + + position + end + + def add_header(form, position, title, group:) + position += 1 + form.form_fields.create!( + question: title, + answer_type: :group_header, + status: :active, + position: position, + is_required: false, + field_key: nil, + field_group: group + ) + position + end + + def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) + position += 1 + field = form.form_fields.create!( + question: question, + answer_type: answer_type, + answer_datatype: datatype, + status: :active, + position: position, + is_required: required, + instructional_hint: hint, + field_key: key, + field_group: group + ) + + if options.present? + options.each_with_index do |opt, idx| + ao = AnswerOption.find_or_create_by!(name: opt) do |a| + a.position = idx + end + field.form_field_answer_options.create!(answer_option: ao) + end + end + + position + end +end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index af5b734e7..c681a75dd 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -123,7 +123,7 @@ def create_mailing_address(person) state: new_state, zip_code: field_value("mailing_zip"), locality: "Unknown", - address_type: "mailing", + address_type: field_value("mailing_address_type")&.downcase || "mailing", primary: true ) end @@ -252,6 +252,7 @@ def update_person_form(person) def save_form_fields(person_form) @form.form_fields.where(status: :active).find_each do |field| next if field.group_header? + next if field.field_key == "confirm_email" raw_value = @form_params[field.id.to_s] text = if raw_value.is_a?(Array) diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb index 9a7e2678b..f6e093eac 100644 --- a/app/services/extended_event_registration_form_builder.rb +++ b/app/services/extended_event_registration_form_builder.rb @@ -1,4 +1,4 @@ -class ExtendedEventRegistrationFormBuilder +class ExtendedEventRegistrationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Extended Event Registration" def self.build_standalone!(include_contact_fields: true) @@ -58,7 +58,8 @@ def build_fields!(form) position = build_professional_fields(form, position) position = build_qualitative_fields(form, position) position = build_scholarship_fields(form, position) - build_payment_fields(form, position) + position = build_payment_fields(form, position) + build_consent_fields(form, position) form end @@ -68,19 +69,15 @@ def build_fields!(form) def build_contact_fields(form, position) position = add_header(form, position, "Contact Information", group: "contact") - position = add_field(form, position, "First Name", :free_form_input_one_line, - key: "first_name", group: "contact", required: true) - position = add_field(form, position, "Last Name", :free_form_input_one_line, - key: "last_name", group: "contact", required: true) + position = build_basic_contact_fields(form, position) + + position = add_field(form, position, "Primary Email Type", :multiple_choice_radio, + key: "primary_email_type", group: "contact", required: true, + options: %w[Personal Work]) position = add_field(form, position, "Preferred Nickname", :free_form_input_one_line, key: "nickname", group: "contact", required: false) position = add_field(form, position, "Pronouns", :free_form_input_one_line, key: "pronouns", group: "contact", required: false) - position = add_field(form, position, "Primary Email", :free_form_input_one_line, - key: "primary_email", group: "contact", required: true) - position = add_field(form, position, "Primary Email Type", :multiple_choice_radio, - key: "primary_email_type", group: "contact", required: true, - options: %w[Personal Work]) position = add_field(form, position, "Secondary Email", :free_form_input_one_line, key: "secondary_email", group: "contact", required: false) position = add_field(form, position, "Secondary Email Type", :multiple_choice_radio, @@ -90,15 +87,15 @@ def build_contact_fields(form, position) position = add_header(form, position, "Mailing Address", group: "contact") position = add_field(form, position, "Street Address", :free_form_input_one_line, key: "mailing_street", group: "contact", required: true) + position = add_field(form, position, "Address Type", :multiple_choice_radio, + key: "mailing_address_type", group: "contact", required: true, + options: %w[Home Work]) position = add_field(form, position, "City", :free_form_input_one_line, key: "mailing_city", group: "contact", required: true) position = add_field(form, position, "State / Province", :free_form_input_one_line, key: "mailing_state", group: "contact", required: true) position = add_field(form, position, "Zip / Postal Code", :free_form_input_one_line, key: "mailing_zip", group: "contact", required: true) - position = add_field(form, position, "Mailing Address Type", :multiple_choice_radio, - key: "mailing_address_type", group: "contact", required: true, - options: %w[Work Personal]) position = add_field(form, position, "Phone", :free_form_input_one_line, key: "phone", group: "contact", required: true) @@ -149,7 +146,7 @@ def build_professional_fields(form, position) key: "primary_service_area", group: "professional", required: false, hint: "Select all that apply. These represent the sectors you primarily serve.") position = add_field(form, position, "Workshop Settings", :multiple_choice_checkbox, - key: "workshop_settings", group: "professional", required: false, + key: "workshop_environments", group: "professional", required: false, hint: "Select all settings where you facilitate or plan to facilitate workshops.", options: [ "Clinical", "Educational", "Events / conferences", @@ -182,30 +179,6 @@ def build_qualitative_fields(form, position) position end - def build_scholarship_fields(form, position) - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - - position - end - def build_payment_fields(form, position) position = add_header(form, position, "Payment Information", group: "payment") @@ -219,46 +192,4 @@ def build_payment_fields(form, position) position end - - # --- helpers --- - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - answer_datatype: datatype, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) do |a| - a.position = idx - end - field.form_field_answer_options.create!(answer_option: ao) - end - end - - position - end end diff --git a/app/services/scholarship_application_form_builder.rb b/app/services/scholarship_application_form_builder.rb index 10331fc2a..b0e42c382 100644 --- a/app/services/scholarship_application_form_builder.rb +++ b/app/services/scholarship_application_form_builder.rb @@ -1,4 +1,4 @@ -class ScholarshipApplicationFormBuilder +class ScholarshipApplicationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Scholarship Application" def self.build_standalone! @@ -15,67 +15,7 @@ def self.build!(event) end def build_fields!(form) - position = 0 - - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - + build_scholarship_fields(form, 0) form end - - private - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - end - - position - end end diff --git a/app/services/short_event_registration_form_builder.rb b/app/services/short_event_registration_form_builder.rb index 7d5ec291f..900d4c6f9 100644 --- a/app/services/short_event_registration_form_builder.rb +++ b/app/services/short_event_registration_form_builder.rb @@ -1,4 +1,4 @@ -class ShortEventRegistrationFormBuilder +class ShortEventRegistrationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Short Event Registration" def self.build_standalone! @@ -17,22 +17,17 @@ def self.build!(event) def build_fields!(form) position = 0 - position = add_field(form, position, "First Name", :free_form_input_one_line, - key: "first_name", group: "contact", required: true) - position = add_field(form, position, "Last Name", :free_form_input_one_line, - key: "last_name", group: "contact", required: true) - position = add_field(form, position, "Enter Email", :free_form_input_one_line, - key: "primary_email", group: "contact", required: true) - position = add_field(form, position, "Confirm Email", :free_form_input_one_line, - key: "confirm_email", group: "contact", required: true) + position = build_basic_contact_fields(form, position) + position = build_consent_fields(form, position) + position = build_qualitative_fields(form, position) + position = build_scholarship_fields(form, position) + + position + end - position = add_field(form, position, "Consent", :multiple_choice_checkbox, - key: "consent", group: "consent", required: true, - hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \ - "including information about this event as well as upcoming events, training opportunities, resources, " \ - "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", - options: [ "I agree to receive email communications from A Window Between Worlds." ]) + private + def build_qualitative_fields(form, position) position = add_field(form, position, "How did you hear about this event?", :multiple_choice_checkbox, key: "referral_source", group: "qualitative", required: true, options: [ "AWBW Email", "Facebook", "Instagram", "LinkedIn", "Online Search", "Word of Mouth", "Other" ]) @@ -42,71 +37,6 @@ def build_fields!(form) key: "training_interest", group: "qualitative", required: true, options: [ "Yes", "Not right now" ]) - position = build_scholarship_fields(form, position) - - position - end - - private - - def build_scholarship_fields(form, position) - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - - position - end - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - end - position end end diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index 7fc392cdd..08b151a7a 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -50,10 +50,10 @@ <% end %> - <% if @event.detail.present? %> + <% if @event.rhino_description.present? %> Details

- <%= simple_format(@event.detail) %> + <%= simple_format(@event.rhino_description.to_plain_text) %>


<% end %> diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index bcbe8a3fa..bd0a2e5f2 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -20,9 +20,9 @@ Videoconference URL: <%= @event.videoconference_url %> <%= @event.labelled_cost %> <% end %> -<% if @event.detail.present? %> +<% if @event.rhino_description.present? %> Event details: - <%= @event.detail %> + <%= @event.rhino_description.to_plain_text %> <% end %> View your registration: diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 151ee27f6..a2c946932 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -2,7 +2,7 @@ <%= hidden_field_tag :return_to, params[:return_to] if params[:return_to].present? %> <%= render 'shared/errors', resource: event_registration if event_registration.errors.any? %> -
+
<% if f.object.persisted? %> @@ -25,10 +25,16 @@
<% if f.object.persisted? %> -
+
<%= person_profile_button(f.object.registrant, subtitle: f.object.registrant.preferred_email) %> - <% f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.each do |org| %> - <%= organization_profile_button(org) %> + <% active_orgs = f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.sort_by(&:name) %> + <% connected_org_ids = f.object.organizations.map(&:id) %> + <% if active_orgs.any? %> +

+ <% active_orgs.each_with_index do |org, i| %> + <%= org.name %><% unless connected_org_ids.include?(org.id) %><% end %><%= ", " unless i == active_orgs.size - 1 %> + <% end %> +

<% end %>
<% else %> @@ -61,6 +67,26 @@ selected: f.object.status %>
+
> + +
+ <% f.object.organizations.sort_by(&:name).each do |org| %> + + <% end %> +
+

Click to toggle. Crossed-out organizations will be removed on save.

+
+
+ +
<% if f.object.event.cost_cents.to_i > 0 %> @@ -79,17 +105,15 @@ <% else %>

Free event

<% end %> - <% if f.object.scholarship? %> - Scholarship recipient - <% end %>
- <% if f.object.scholarship_requested? %> - Scholarship requested - <% end %> - <%= f.input :scholarship_recipient, as: :boolean %> - <%= f.input :scholarship_tasks_completed, as: :boolean, label: "Scholarship tasks completed" %> +
+
Scholarship
+ <%= f.input :scholarship_requested, as: :boolean %> + <%= f.input :scholarship_recipient, as: :boolean %> + <%= f.input :scholarship_tasks_completed, as: :boolean, label: "Scholarship tasks completed" %> +
<% end %> @@ -126,12 +150,15 @@
<% if allowed_to?(:destroy?, f.object) %> - <%= link_to "Delete", @event_registration, class: "btn btn-danger-outline", - data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> + <%= link_to "Delete", event_registration_path(@event_registration, return_to: params[:return_to].presence), class: "btn btn-danger-outline", + data: { turbo: true, turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> - <%= link_to "Cancel", - (allowed_to?(:index?, EventRegistration) ? event_registrations_path : root_path), - class: "btn btn-secondary-outline" %> + <% cancel_path = case params[:return_to] + when "manage" then manage_event_path(event_registration.event) + when "index" then event_registrations_path + else allowed_to?(:index?, EventRegistration) ? event_registrations_path : root_path + end %> + <%= link_to "Cancel", cancel_path, class: "btn btn-secondary-outline" %> <%= f.button :submit, class: "btn btn-primary" %>
diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb index 71bb042d4..a807f6c01 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -7,12 +7,14 @@ <%= EventRegistration.model_name.human.pluralize %> (<%= @event_registrations_count %>)
+ <%= link_to "Events", events_path, + class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <%= link_to "Export CSV", event_registrations_path(request.query_parameters.merge(format: :csv)), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if allowed_to?(:new?, EventRegistration) %> <%= link_to "New #{EventRegistration.model_name.human.downcase}", - new_event_registration_path, + new_event_registration_path(return_to: "index"), class: "admin-only bg-blue-100 btn btn-primary-outline" %> <% end %>
@@ -42,16 +44,18 @@ + <% all_event_form_ids = @event_registrations.flat_map { |er| er.event.event_forms.map(&:form_id) }.uniq %> + <% submitted_pairs = all_event_form_ids.any? ? PersonForm.where(form_id: all_event_form_ids, person_id: @event_registrations.map(&:registrant_id).uniq).pluck(:person_id, :form_id).to_set : Set.new %> <% @event_registrations.each do |event_registration| %>
<%= person_profile_button(event_registration.registrant) %> - <% reg_form = event_registration.event.registration_form %> - <% if reg_form && reg_form.person_forms.exists?(person: event_registration.registrant) %> + <% er_form_ids = event_registration.event.event_forms.map(&:form_id) %> + <% if er_form_ids.any? && er_form_ids.any? { |fid| submitted_pairs.include?([ event_registration.registrant_id, fid ]) } %> - <% elsif reg_form %> + <% elsif er_form_ids.any? %> <% end %>
@@ -72,7 +76,7 @@
<%= link_to "View", event_registration_path(event_registration), class: "btn btn-secondary-outline" %> - <%= link_to "Edit", edit_event_registration_path(event_registration), + <%= link_to "Edit", edit_event_registration_path(event_registration, return_to: "index"), class: "btn btn-secondary-outline" %>
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb index 15fccb9a4..004bdfd39 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -3,8 +3,8 @@

<%= @event_registrations.size %> registrant<%= "s" if @event_registrations.size != 1 %>

- <% reg_form = @event.registration_form %> - <% form_submissions = reg_form ? reg_form.person_forms.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %> + <% event_form_ids = @event.form_ids %> + <% form_submissions = event_form_ids.any? ? PersonForm.joins(:form).where(form_id: event_form_ids, person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at, Arel.sql("forms.name")).group_by(&:first).transform_values { |rows| rows.map { |_, ts, name| [ name, ts ] } } : {} %> <% if @event_registrations.any? %>
@@ -28,17 +28,18 @@ <% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %>
<%= person_profile_button(person, subtitle: (person.preferred_email if show_email), data: { turbo_frame: "_top" }) %> - <% if reg_form && form_submissions.key?(person.id) %> - <% submitted_at = form_submissions[person.id] %> + <% if event_form_ids.any? && (submissions = form_submissions[person.id]) %> <% form_show_params = registration.slug.present? ? { reg: registration.slug } : { person_id: person.id } %> + <% tooltip_parts = submissions.map { |name, ts| "#{name} — Submitted #{ts.strftime('%B %d, %Y at %l:%M %P')}" } %> + <% tooltip_parts << "Scholarship requested" if registration.scholarship_requested? %> <%= link_to event_public_registration_path(@event, **form_show_params), class: "text-green-600 hover:text-green-800", - title: "#{reg_form.name} — Submitted #{submitted_at.strftime('%B %d, %Y at %l:%M %P')}#{' — Scholarship requested' if registration.scholarship_requested?}", + title: tooltip_parts.join("\n"), target: "_blank", data: { turbo_frame: "_top" } do %> <% end %> - <% elsif reg_form %> + <% elsif event_form_ids.any? %> <% end %> <% if registration.comments.any? %> @@ -51,20 +52,12 @@
- <% orgs = person.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq %> - <% if orgs.any? %> -
- <% orgs.first(3).each do |org| %> - <%= link_to org.name, organization_path(org), - data: { turbo_frame: "_top" }, - class: "inline-block px-3 py-1 rounded-md text-sm font-medium #{DomainTheme.bg_class_for(:organizations, intensity: 100)} #{DomainTheme.text_class_for(:organizations)} #{DomainTheme.bg_class_for(:organizations, intensity: 100, hover: true)}" %> + <% if registration.organizations.any? %> +
    + <% registration.organizations.each do |org| %> +
  • <%= org.name %>
  • <% end %> - <% if orgs.size > 3 %> - <%= link_to "+#{orgs.size - 3}", person_path(person), - data: { turbo_frame: "_top" }, - class: "inline-block px-3 py-1 rounded-md text-sm font-medium text-gray-500 hover:text-gray-700" %> - <% end %> -
+ <% else %> — <% end %> diff --git a/app/views/events/manage.html.erb b/app/views/events/manage.html.erb index 013ed4fdb..cb16c0305 100644 --- a/app/views/events/manage.html.erb +++ b/app/views/events/manage.html.erb @@ -15,7 +15,7 @@ <%= link_to "View", event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if allowed_to?(:edit?, @event) %> <%= link_to "Edit", edit_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <%= link_to "New registration", new_event_registration_path(event_id: @event.id), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "New registration", new_event_registration_path(event_id: @event.id, return_to: "manage"), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if @event.event_forms.registration.exists? %> <%= link_to "Public registration", new_event_public_registration_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1", target: "_blank" %> <%= link_to "Scholarship version", new_event_public_registration_path(@event, scholarship: 1), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1", target: "_blank" %> diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index d5fe9730f..9cf64881d 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -1,4 +1,4 @@ -<%# locals: (field:, value: nil) %> +<%# locals: (field:, value: nil, label: nil) %> <% return if field.group_header? %> <% error = @field_errors&.dig(field.id) %> @@ -6,7 +6,7 @@
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index 94373ae41..b94b25005 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -32,6 +32,7 @@ { keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } }, { keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } }, + { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" }, { keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, @@ -47,6 +48,7 @@ %> <% @form_fields.each do |field| %> + <% next if field.field_key == "confirm_email" %> <% next if field.field_key.present? && rendered_keys[field.field_key] %> <% if field.group_header? %> diff --git a/app/views/forms/_form.html.erb b/app/views/forms/_form.html.erb new file mode 100644 index 000000000..81c315817 --- /dev/null +++ b/app/views/forms/_form.html.erb @@ -0,0 +1,76 @@ +<%= simple_form_for(@form, html: { data: { controller: "dirty-form" } }) do |f| %> + <%= render "shared/errors", resource: @form if @form.errors.any? %> + +
+
+ <%= f.input :name, label: "Form name", + input_html: { class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm" } %> +
+
+ <%= f.input :scholarship_application, as: :boolean %> +
+
+ + <%# Fields grouped by section %> +
+ <% @field_groups.each do |group| %> +
+
+

<%= group.presence || "Ungrouped" %>

+
+ +
+ <% (@form_fields_by_group[group] || []).each do |field| %> + <%= f.simple_fields_for :form_fields, field do |ff| %> + <%= render "form_field_row", ff: ff, field: field %> + <% end %> + <% end %> +
+ +
+ <%= link_to_add_association "Add blank field", f, :form_fields, + class: "text-blue-600 hover:underline text-sm", + data: { + association_insertion_node: "#fields_#{group}", + association_insertion_method: "append" + } %> +
+
+ <% end %> + + <%# Show ungrouped section if no groups exist yet %> + <% if @field_groups.empty? %> +
+

Fields

+ +
+
+ +
+ <%= link_to_add_association "Add blank field", f, :form_fields, + class: "text-blue-600 hover:underline text-sm", + data: { + association_insertion_node: "#fields_new", + association_insertion_method: "append" + } %> +
+
+ <% end %> +
+ +
+ <% if allowed_to?(:destroy?, @form) %> + <%= link_to "Delete", @form, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this form?" } %> + <% end %> + <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %> + <%= f.button :submit, class: "btn btn-primary" %> +
+<% end %> + +<%# Question Library (outside the main form to avoid nested forms) %> +
+ <%= turbo_frame_tag "question_library", src: question_library_form_path(@form), loading: :lazy do %> +

Loading question library...

+ <% end %> +
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb new file mode 100644 index 000000000..11c0a19f5 --- /dev/null +++ b/app/views/forms/_form_field_fields.html.erb @@ -0,0 +1,44 @@ +
+
+ <%# Question label %> +
+ <%= f.input :question, label: false, placeholder: "Question text", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-sm" } %> +
+ + <%# Answer type %> +
+ <%= f.input :answer_type, + collection: FormField.answer_types.keys.map { |k| [ k.humanize, k ] }, + label: false, + input_html: { class: "w-full text-xs rounded border-gray-300" } %> +
+ + <%# Field group %> +
+ <%= f.input :field_group, label: false, placeholder: "Group", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs" } %> +
+ + <%# Field key %> +
+ <%= f.input :field_key, label: false, placeholder: "field_key", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs font-mono text-gray-500" } %> +
+ + <%# Required %> +
+ +
+ + <%# Remove %> +
+ <%= link_to_remove_association "Remove", + f, + class: "text-xs text-red-600 hover:text-red-800 underline dynamic" %> +
+
+
diff --git a/app/views/forms/_form_field_row.html.erb b/app/views/forms/_form_field_row.html.erb new file mode 100644 index 000000000..082550ea5 --- /dev/null +++ b/app/views/forms/_form_field_row.html.erb @@ -0,0 +1,47 @@ +
+ <%= ff.hidden_field :id %> + +
+ <%# Question label (editable) %> +
+ <%= ff.input :question, label: false, placeholder: "Question text", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-sm" } %> +
+ + <%# Answer type %> +
+ <%= ff.input :answer_type, + collection: FormField.answer_types.keys.map { |k| [ k.humanize, k ] }, + label: false, + input_html: { class: "w-full text-xs rounded border-gray-300" } %> +
+ + <%# Field group %> +
+ <%= ff.input :field_group, label: false, placeholder: "Group", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs" } %> +
+ + <%# Field key (readonly reference) %> +
+ <%= ff.input :field_key, label: false, placeholder: "field_key", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs font-mono text-gray-500", + readonly: field.persisted? } %> +
+ + <%# Required + Status toggles %> +
+ +
+ + <%# Remove %> +
+ <%= link_to_remove_association "Remove", + ff, + class: "text-xs text-red-600 hover:text-red-800 underline" %> +
+
+
diff --git a/app/views/forms/_question_library.html.erb b/app/views/forms/_question_library.html.erb new file mode 100644 index 000000000..659a9437c --- /dev/null +++ b/app/views/forms/_question_library.html.erb @@ -0,0 +1,46 @@ +<%= turbo_frame_tag "question_library" do %> +
+

Add questions from library

+

Select questions from other forms to add to this form.

+ + <% if @available_fields.present? %> + <%= form_with url: add_questions_form_path(@form), method: :post do |f| %> +
+ <%# Search filter %> + + +
+ <% @available_fields.each do |group, fields| %> +
+

<%= group.presence || "Ungrouped" %>

+
+ <% fields.uniq(&:field_key).each do |field| %> + + <% end %> +
+
+ <% end %> +
+ + +
+ <% end %> + <% else %> +

No additional questions available to add.

+ <% end %> +
+<% end %> diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb new file mode 100644 index 000000000..f6cfb6df9 --- /dev/null +++ b/app/views/forms/edit.html.erb @@ -0,0 +1,16 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "View", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+

Edit form

+ +
+
+ <%= render "form" %> + <%= render "shared/audit_info", resource: @form %> +
+
+
diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb new file mode 100644 index 000000000..e9447b98d --- /dev/null +++ b/app/views/forms/index.html.erb @@ -0,0 +1,66 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+

Forms

+

Manage registration and application forms.

+
+
+ <% if allowed_to?(:new?, Form) %> + <%= link_to "New Form", new_form_path, class: "admin-only bg-blue-100 btn btn-primary-outline" %> + <% end %> +
+
+ +
+ <% if @forms.present? %> +
+ + + + + + + + + + + + <% @forms.each do |form| %> + + + + + + + + <% end %> + +
NameFieldsLinked EventsTypeActions
+ <%= link_to form.display_name, form_path(form), class: "hover:text-blue-700" %> + + <%= form.form_fields.size %> + + <% if form.events.any? %> + <%= form.events.map(&:title).join(", ") %> + <% else %> + None + <% end %> + + <% if form.scholarship_application? %> + Scholarship + <% elsif form.owner_id.nil? %> + Standalone + <% else %> + Owned + <% end %> + + <%= link_to "Edit", edit_form_path(form), class: "btn btn-secondary-outline" %> +
+
+ + <% else %> +

No forms found.

+ <% end %> +
+
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb new file mode 100644 index 000000000..0c9787695 --- /dev/null +++ b/app/views/forms/new.html.erb @@ -0,0 +1,38 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+ +

Create a new form

+

Choose a template to start with, or create a blank form.

+ +
+ <% @builders.each do |builder| %> + <% if builder[:key] == "generic" %> + <%= form_with url: forms_path, method: :post, class: "block" do %> + +
+

<%= builder[:name] %>

+

<%= builder[:description] %>

+
+ + +
+
+ <% end %> + <% else %> + <%= form_with url: forms_path, method: :post, class: "block" do %> + + + <% end %> + <% end %> + <% end %> +
+
diff --git a/app/views/forms/question_library.html.erb b/app/views/forms/question_library.html.erb new file mode 100644 index 000000000..d03cbc8f7 --- /dev/null +++ b/app/views/forms/question_library.html.erb @@ -0,0 +1 @@ +<%= render "question_library" %> diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb new file mode 100644 index 000000000..ea0beaf96 --- /dev/null +++ b/app/views/forms/show.html.erb @@ -0,0 +1,76 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:edit?, @form) %> + <%= link_to "Edit", edit_form_path(@form), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> +
+ +
+

<%= @form.display_name %>

+ <% if @form.scholarship_application? %> + Scholarship + <% end %> +
+ + <%# Linked Events %> + <% if @linked_events.any? %> +
+

Linked Events

+
+ <% @linked_events.each do |event| %> + <%= link_to event.title, event_path(event), + class: "inline-block px-3 py-1 text-sm bg-blue-50 text-blue-700 rounded hover:bg-blue-100" %> + <% end %> +
+
+ <% end %> + + <%# Form Fields by Group %> +
+ <% @form_fields_by_group.each do |group, fields| %> +
+

<%= group.presence || "Ungrouped" %>

+
+ <% fields.each do |field| %> +
+
+
+ <%= field.question %> + <% if field.is_required? %> + Required + <% end %> + <% if field.inactive? %> + Inactive + <% end %> +
+ <% if field.instructional_hint.present? %> +

<%= field.instructional_hint %>

+ <% end %> +
+
+ <%= field.answer_type.humanize %> + <% if field.field_key.present? %> + <%= field.field_key %> + <% end %> +
+
+ <% end %> +
+
+ <% end %> +
+ + <%# Actions %> +
+ <% if allowed_to?(:edit?, @form) %> + <%= link_to "Edit", edit_form_path(@form), class: "btn btn-primary-outline" %> + <% end %> + <% if allowed_to?(:destroy?, @form) %> + <%= link_to "Delete", @form, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this form?" } %> + <% end %> +
+
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 7805f3c6a..51b83e1f4 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -379,10 +379,10 @@ <% if f.object.persisted? %> <% if allowed_to?(:manage?, Comment) %> -
+
Comments
-
<%= f.simple_fields_for :comments do |cf| %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index af8e2b48f..048793e04 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -21,10 +21,12 @@
<% end %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <%= link_to "People", people_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:index?, Person) %> + <%= link_to "People", people_path, class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> <% if allowed_to?(:edit?, @person) %> <%= link_to "Edit", edit_person_path(@person), - class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% end %> <%= render "bookmarks/editable_bookmark_button", resource: @person.object %> @@ -66,10 +68,16 @@
<% email = @person.user&.email || @person.email %> - <% if (@person.profile_show_email? || allowed_to?(:manage?, Person)) && email.present? %> - - 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> - + <% if email.present? %> + <% if @person.profile_show_email? %> + + 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> + + <% elsif allowed_to?(:manage?, Person) %> + + 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> + + <% end %> <% end %> <% phone = @person.phone_number || @person.user&.phone %> <% if @person.profile_show_phone? && phone.present? %> @@ -210,7 +218,7 @@
<% end %> - <% if allowed_to?(:edit?, @person) %> + <% if allowed_to?(:show?, @person) %>
@@ -263,12 +271,6 @@
<% end %> - - <% if allowed_to?(:manage?, Comment) %> -
- <%= render "comments/section", commentable: @person.object %> -
- <% end %>
diff --git a/app/views/shared/_navbar_user.html.erb b/app/views/shared/_navbar_user.html.erb index 37d84fe64..16558087a 100644 --- a/app/views/shared/_navbar_user.html.erb +++ b/app/views/shared/_navbar_user.html.erb @@ -31,7 +31,7 @@ <% end %> <% end %> - <% if person && allowed_to?(:show?, person) %> + <% if person && allowed_to?(:manage?, Person) %> <%= link_to person_path(person), class: "admin-only bg-blue-100 flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 space-x-2" do %> diff --git a/app/views/story_shares/_sector_index.html.erb b/app/views/story_shares/_sector_index.html.erb index 1f18e4140..fb5741bc2 100644 --- a/app/views/story_shares/_sector_index.html.erb +++ b/app/views/story_shares/_sector_index.html.erb @@ -41,7 +41,7 @@

- <%= truncate(strip_tags(story.body.to_s), length: 140) %> + <%= truncate(story.rhino_body.to_plain_text, length: 140) %>

diff --git a/app/views/story_shares/_sector_index_row.html.erb b/app/views/story_shares/_sector_index_row.html.erb index 45498dccb..d04a110e5 100644 --- a/app/views/story_shares/_sector_index_row.html.erb +++ b/app/views/story_shares/_sector_index_row.html.erb @@ -14,7 +14,7 @@

- <%= truncate(strip_tags(story.body.to_s), length: 280) %> + <%= truncate(story.rhino_body.to_plain_text, length: 280) %>

diff --git a/app/views/tutorials/_form.html.erb b/app/views/tutorials/_form.html.erb index a2c17caa6..ef211188b 100644 --- a/app/views/tutorials/_form.html.erb +++ b/app/views/tutorials/_form.html.erb @@ -33,8 +33,7 @@ <%= render "shared/form_image_fields", f: f, include_primary_asset: true %>
- <%= f.input :body, as: :text, - input_html: { rows: 10, value: f.object.body } %> + <%= f.input :body, as: :text, input_html: { rows: 10, value: f.object.body } %>
diff --git a/app/views/tutorials/_tutorial.html.erb b/app/views/tutorials/_tutorial.html.erb index 20c7f619d..119ca7e8c 100644 --- a/app/views/tutorials/_tutorial.html.erb +++ b/app/views/tutorials/_tutorial.html.erb @@ -35,9 +35,9 @@ <% end %> - <% if tutorial.body.present? %> + <% if tutorial.rhino_body.present? %>
- <%= tutorial.body.to_s.html_safe %> + <%= tutorial.rhino_body %>
<% end %> diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 1ff64fb68..f4c1a93df 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -140,7 +140,7 @@ <% end %>
- <% if allowed_to?(:destroy?, f.object) %> + <% if allowed_to?(:destroy?, f.object) && f.object.deletable? %> <%= link_to "Delete", @user, class: "btn btn-danger-outline", data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> diff --git a/app/views/workshop_logs/show.html.erb b/app/views/workshop_logs/show.html.erb index d6f3f8ac5..3318e59fb 100644 --- a/app/views/workshop_logs/show.html.erb +++ b/app/views/workshop_logs/show.html.erb @@ -12,7 +12,7 @@
-

<%= @workshop_log.workshop&.title || "Workshop Log" %>

+

<%= @workshop_log.workshop&.title || @workshop_log.external_workshop_title || "Workshop Log" %>

@@ -49,8 +49,18 @@ <%= link_to @workshop_log.workshop.title, workshop_path(@workshop_log.workshop), data: { turbo_frame: "_top" }, class: "inline-block px-3 py-1 rounded-md text-sm font-medium #{DomainTheme.bg_class_for(:workshops, intensity: 100)} #{DomainTheme.text_class_for(:workshops)} #{DomainTheme.bg_class_for(:workshops, intensity: 100, hover: true)}" %> + <% if @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> + <% end %> + <% elsif @workshop_log.workshop %> + <%= @workshop_log.workshop.title %> + <% if @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> + <% end %> + <% elsif @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> <% else %> - <%= @workshop_log.workshop&.title || "—" %> + — <% end %>
<%= @workshop_log.date&.strftime("%B %d, %Y") || "—" %>
diff --git a/app/views/workshop_variations/index.html.erb b/app/views/workshop_variations/index.html.erb index a15b3fe52..4f17fbd8c 100644 --- a/app/views/workshop_variations/index.html.erb +++ b/app/views/workshop_variations/index.html.erb @@ -41,8 +41,9 @@
<%= truncate(workshop_variation.name, length: 30) %> - - <%= truncate(strip_tags(workshop_variation.body), length: 30) %> + <% plain = workshop_variation.rhino_body.to_plain_text %> + + <%= truncate(plain, length: 30) %> diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 3a60feceb..e84f6f791 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -44,14 +44,16 @@
<% if allowed_to?(:manage?, Workshop) %> -
- <%= f.input :full_name, as: :text, - label: "Author's name", - hint: "Display name", - input_html: { - rows: 1, - class: "block w-full rounded-md border-gray-300 shadow-sm - focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ <% if f.object.full_name.present? && params[:admin] == "true" %> + <%= f.input :full_name, as: :text, + label: "Author's name", + hint: "Display name", + input_html: { + rows: 1, + class: "block w-full rounded-md border-gray-300 shadow-sm + focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <% end %>
<%= f.input :created_by_id, @@ -74,7 +76,7 @@ label: "Author credit preference", hint: "Controls how the author's name is displayed" %>
-
+
<%= f.input :workshop_idea_id, as: :select, label: "Workshop Idea parent", @@ -84,6 +86,24 @@ hint: "If this workshop was created from a Workshop Idea, select it here.", input_html: { value: f.object.workshop_idea_id, class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ +
+ <%= f.input :month, as: :select, + collection: Date::MONTHNAMES.compact.each_with_index.map { |m, i| [m, i + 1] }, + selected: f.object.month || Date.today.month, + wrapper: false, label: false, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <%= f.input :year, + as: :integer, + wrapper: false, + label: false, + input_html: { + value: f.object.year || Date.today.year, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + } %> +
+
@@ -228,28 +248,6 @@ <%= rhino_editor(f, :extra_field) %> <% end %> - <% if allowed_to?(:manage?, Workshop) %> -
- - -
- <%= f.input :month, as: :select, - collection: Date::MONTHNAMES.compact.each_with_index.map { |m, i| [m, i + 1] }, - selected: f.object.month || Date.today.month, - wrapper: false, label: false, - input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> - - <%= f.input :year, - as: :integer, - wrapper: false, - label: false, - input_html: { - value: f.object.year || Date.today.year, - class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - } %> -
-
- <% end %> <% end %> diff --git a/app/views/workshops/edit.html.erb b/app/views/workshops/edit.html.erb index 68e8bf5c8..1794ed784 100644 --- a/app/views/workshops/edit.html.erb +++ b/app/views/workshops/edit.html.erb @@ -16,11 +16,5 @@ <%= render "form", workshop: @workshop %> <%= render "shared/audit_info", resource: @workshop %>
- <% if params[:admin] == 'true' %> -
<%= render "assets/form", owner: @workshop %>
- <%= turbo_frame_tag "editor_assets_lazy", src: edit_workshop_path(@workshop) do %> - <%= render "shared/loading" %> - <% end %> - <% end %>
diff --git a/config/routes.rb b/config/routes.rb index bbd10c7d9..a511b0f2f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,12 @@ resources :comments, only: [ :index, :create ] end resources :faqs + resources :forms do + member do + get :question_library + post :add_questions + end + end resources :notifications, only: [ :index, :show ] do member do post :resend diff --git a/db/migrate/20260301143000_create_event_registration_organizations.rb b/db/migrate/20260301143000_create_event_registration_organizations.rb new file mode 100644 index 000000000..f151d00e4 --- /dev/null +++ b/db/migrate/20260301143000_create_event_registration_organizations.rb @@ -0,0 +1,15 @@ +class CreateEventRegistrationOrganizations < ActiveRecord::Migration[8.1] + def change + create_table :event_registration_organizations do |t| + t.references :event_registration, null: false, foreign_key: true + t.references :organization, null: false, foreign_key: true, type: :integer + + t.timestamps + end + + add_index :event_registration_organizations, + [ :event_registration_id, :organization_id ], + unique: true, + name: "idx_event_reg_orgs_on_registration_and_org" + end +end diff --git a/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb b/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb new file mode 100644 index 000000000..4e950d49b --- /dev/null +++ b/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb @@ -0,0 +1,5 @@ +class MakeWorkshopIdOptionalOnReports < ActiveRecord::Migration[8.1] + def change + change_column_null :reports, :workshop_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index ee45b0729..c5332b2a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_01_120200) do +ActiveRecord::Schema[8.1].define(version: 2026_03_01_150000) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -408,6 +408,16 @@ t.index ["form_id"], name: "index_event_forms_on_form_id" end + create_table "event_registration_organizations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "event_registration_id", null: false + t.integer "organization_id", null: false + t.datetime "updated_at", null: false + t.index ["event_registration_id", "organization_id"], name: "idx_event_reg_orgs_on_registration_and_org", unique: true + t.index ["event_registration_id"], name: "idx_on_event_registration_id_806bdcd019" + t.index ["organization_id"], name: "index_event_registration_organizations_on_organization_id" + end + create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "event_id" @@ -820,7 +830,7 @@ t.string "type" t.datetime "updated_at", precision: nil, null: false t.integer "windows_type_id", null: false - t.integer "workshop_id", null: false + t.integer "workshop_id" t.string "workshop_name" t.index ["created_by_id"], name: "index_reports_on_created_by_id" t.index ["organization_id"], name: "index_reports_on_organization_id" @@ -1342,6 +1352,8 @@ add_foreign_key "contact_methods", "addresses" add_foreign_key "event_forms", "events" add_foreign_key "event_forms", "forms" + add_foreign_key "event_registration_organizations", "event_registrations" + add_foreign_key "event_registration_organizations", "organizations" add_foreign_key "event_registrations", "events" add_foreign_key "event_registrations", "people", column: "registrant_id" add_foreign_key "events", "locations" diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index 4bc35700e..9cf0f20d0 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -943,7 +943,250 @@ end end +puts "Creating Event Registrations…" +# Key people for named scenarios +amy_person = User.find_by(email: "amy.user@example.com")&.person +maria_j = Person.find_by(first_name: "Maria", last_name: "Johnson") +anna_g = Person.find_by(first_name: "Anna", last_name: "Garcia") +sarah_s = Person.find_by(first_name: "Sarah", last_name: "Smith") +lisa_w = Person.find_by(first_name: "Lisa", last_name: "Williams") +jessica_b = Person.find_by(first_name: "Jessica", last_name: "Brown") +kim_d = Person.find_by(first_name: "Kim", last_name: "Davis") +rosa_dlc = Person.find_by(first_name: "Rosa", last_name: "De La Cruz") +mario_j = Person.find_by(first_name: "Mario", last_name: "Johnson") # no user +angel_g = Person.find_by(first_name: "Angel", last_name: "Garcia") # no user +linda_w = Person.find_by(first_name: "Linda", last_name: "Williams") # no user + +# Events by name for clarity +facilitator_training = Event.find_by(title: "AWBW Facilitator Training") +trauma_training = Event.find_by(title: "Facilitator Training: Trauma-Informed Art Practices") +wellness_day = Event.find_by(title: "A Year of Healing and Rebuilding Together Wellness Day") +youth_day = Event.find_by(title: "Youth Creativity Day") +mindful_art = Event.find_by(title: "Mindful Art for Survivors Workshop") +virtual_session = Event.find_by(title: "Art as Healing: Virtual Group Session") +roundtable = Event.find_by(title: "Leaders in Creativity: Facilitator Roundtable") +family_day = Event.find_by(title: "Family Creative Expression Day") +# "Community Open Studio Night" and "Annual Celebration of Voices" have no registration forms — left with zero registrations + +registrations_data = [] + +# --- Facilitator Training: multiple registrations from different people, extended form --- +# Amy: registered, with form submission, scholarship recipient +# Maria Johnson: registered, with form submission (has user) +# Anna Garcia: attended, with form submission (has user) +# Mario Johnson: registered, no form submission (no user) +# Kim Davis: cancelled (has user) +if facilitator_training + [ + { person: amy_person, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: false }, + { person: maria_j, status: "registered" }, + { person: anna_g, status: "attended" }, + { person: mario_j, status: "registered" }, + { person: kim_d, status: "cancelled" } + ].each do |data| + next unless data[:person] + registrations_data << data.merge(event: facilitator_training) + end +end + +# --- Trauma Training: extended form, scholarship --- +# Sarah Smith: registered with form (has user) +# Jessica Brown: registered with form, scholarship (has user) +# Angel Garcia: registered, no form (no user) +# Linda Williams: no_show (no user) +if trauma_training + [ + { person: sarah_s, status: "registered" }, + { person: jessica_b, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: true }, + { person: angel_g, status: "registered" }, + { person: linda_w, status: "no_show" } + ].each do |data| + next unless data[:person] + registrations_data << data.merge(event: trauma_training) + end +end + +# --- Amy registered to multiple events (person registered across events) --- +if amy_person + [ wellness_day, mindful_art, virtual_session ].compact.each do |evt| + registrations_data << { person: amy_person, event: evt, status: "registered" } + end +end + +# --- Maria Johnson also registered to multiple events --- +if maria_j + [ wellness_day, youth_day ].compact.each do |evt| + registrations_data << { person: maria_j, event: evt, status: "registered" } + end +end + +# --- Rosa De La Cruz registered to a couple events (has user) --- +if rosa_dlc + [ wellness_day, family_day ].compact.each do |evt| + registrations_data << { person: rosa_dlc, event: evt, status: "registered" } + end +end + +# --- Lisa Williams: incomplete_attendance on one event --- +if lisa_w && roundtable + registrations_data << { person: lisa_w, event: roundtable, status: "incomplete_attendance" } +end + +# --- People with multiple active affiliations — ensures org snapshots get exercised --- +mariana_j = Person.find_by(first_name: "Mariana", last_name: "Johnson") +samuel_s = Person.find_by(first_name: "Samuel", last_name: "Smith") +lisa_wn = Person.find_by(first_name: "Lisa", last_name: "Williamson") +kim_dv = Person.find_by(first_name: "Kim", last_name: "Davidson") +sarah_d = Person.find_by(first_name: "Sarah", last_name: "Davis") + +{ mariana_j => youth_day, samuel_s => mindful_art, lisa_wn => virtual_session, + kim_dv => family_day, sarah_d => roundtable }.each do |person, evt| + next unless person && evt + registrations_data << { person: person, event: evt, status: "registered" } +end + +# --- Wellness Day gets extra registrations (popular free event, short form) --- +if wellness_day + [ sarah_s, jessica_b, lisa_w, kim_d ].compact.each do |person| + registrations_data << { person: person, event: wellness_day, status: "registered" } + end +end + +# Create all registrations +registrations_data.each do |data| + next unless data[:event] && data[:person] + next if EventRegistration.exists?(event: data[:event], registrant: data[:person]) + + EventRegistration.create!( + event: data[:event], + registrant: data[:person], + status: data[:status] || "registered", + scholarship_recipient: data[:scholarship_recipient] || false, + scholarship_tasks_completed: data[:scholarship_tasks_completed] || false, + scholarship_requested: data[:scholarship_recipient] || false + ) +end + +puts "Creating Registration Form Submissions…" +# Create person_form records linking registrants to their event's registration form. +# This simulates people who filled out the registration form. +form_submissions = [] + +# Facilitator Training (extended form) — some registrants filled it out, one didn't +if facilitator_training + reg_form = facilitator_training.registration_form + if reg_form + # People with users who filled out the form + [ amy_person, maria_j, anna_g ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Mario Johnson (no user) did NOT fill out the form — registration without form submission + end + + # Amy also filled out the scholarship form + scholarship_f = facilitator_training.scholarship_form + if scholarship_f && amy_person + form_submissions << { person: amy_person, form: scholarship_f } + end +end + +# Trauma Training (extended form) +if trauma_training + reg_form = trauma_training.registration_form + if reg_form + # Sarah Smith (has user) and Jessica Brown (has user) filled out forms + [ sarah_s, jessica_b ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Angel Garcia (no user) filled out the form — person without user + form + form_submissions << { person: angel_g, form: reg_form } if angel_g + # Linda Williams (no user) did NOT fill out the form + end + + # Jessica filled out the scholarship form + scholarship_f = trauma_training.scholarship_form + if scholarship_f && jessica_b + form_submissions << { person: jessica_b, form: scholarship_f } + end +end + +# Wellness Day (short form) — most filled it out +if wellness_day + reg_form = wellness_day.registration_form + if reg_form + # People with users + [ amy_person, maria_j, sarah_s, jessica_b, kim_d ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Rosa (has user) filled it out too + form_submissions << { person: rosa_dlc, form: reg_form } if rosa_dlc + # Lisa Williams (has user) registered but didn't fill out the form — person with user + no form + end +end + +# Mindful Art (short form, has scholarship) — Amy filled out both +if mindful_art + reg_form = mindful_art.registration_form + form_submissions << { person: amy_person, form: reg_form } if reg_form && amy_person + + scholarship_f = mindful_art.scholarship_form + form_submissions << { person: amy_person, form: scholarship_f } if scholarship_f && amy_person +end + +# Youth Day (short form) — Maria filled it out +if youth_day + reg_form = youth_day.registration_form + form_submissions << { person: maria_j, form: reg_form } if reg_form && maria_j +end + +# Virtual Session (short form) — Amy (has user) registered but no form submission — person with user + no form +# Family Day (short form) — Rosa filled it out +if family_day + reg_form = family_day.registration_form + form_submissions << { person: rosa_dlc, form: reg_form } if reg_form && rosa_dlc +end + +# Create all form submissions with sample field responses +form_submissions.each do |data| + next unless data[:person] && data[:form] + next if PersonForm.exists?(person: data[:person], form: data[:form]) + + pf = PersonForm.create!(person: data[:person], form: data[:form]) + + # Fill in required text fields with sample data + data[:form].form_fields.where(answer_type: [ :free_form_input_one_line, :free_form_input_paragraph ]).each do |field| + sample_text = case field.field_key + when "first_name" then data[:person].first_name + when "last_name" then data[:person].last_name + when "primary_email", "enter_email", "confirm_email" then data[:person].preferred_email || "sample@example.com" + when "phone" then "(555) #{rand(100..999)}-#{rand(1000..9999)}" + when "street_address", "agency_street_address" then Faker::Address.street_address + when "city", "agency_city" then Faker::Address.city + when "state_province", "agency_state_province" then Faker::Address.state_abbr + when "zip_postal_code", "agency_zip_postal_code" then Faker::Address.zip_code + when "agency_organization_name" then Faker::Company.name + when "position_title" then "Facilitator" + when "agency_website" then "https://example.org" + when "racial_ethnic_identity" then "Prefer not to say" + when "secondary_email" then data[:person].email_2 + when "preferred_nickname" then data[:person].first_name + when "pronouns" then [ "she/her", "he/him", "they/them" ].sample + else + if field.answer_type == "free_form_input_paragraph" + Faker::Lorem.paragraph(sentence_count: 3) + else + Faker::Lorem.word.capitalize + end + end + + PersonFormFormField.create!( + person_form: pf, + form_field: field, + text: sample_text.to_s + ) + end +end puts "Creating Resources…" 10.times do |i| diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index d188127fb..a9c155abf 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -18,6 +18,7 @@ module DomainTheme categories: :lime, category_types: :lime, + forms: :stone, faqs: :pink, tutorials: :cyan, diff --git a/spec/factories/event_registration_organizations.rb b/spec/factories/event_registration_organizations.rb new file mode 100644 index 000000000..624721636 --- /dev/null +++ b/spec/factories/event_registration_organizations.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :event_registration_organization do + association :event_registration + association :organization + end +end diff --git a/spec/factories/forms.rb b/spec/factories/forms.rb index 26d2b2570..5f5ee3e22 100644 --- a/spec/factories/forms.rb +++ b/spec/factories/forms.rb @@ -9,5 +9,16 @@ trait :with_owner do association :owner, factory: :user end + + trait :with_fields do + after(:create) do |form| + create_list(:form_field, 3, form: form) + end + end + + trait :scholarship do + scholarship_application { true } + name { "Scholarship Application" } + end end end diff --git a/spec/factories/person_forms.rb b/spec/factories/person_forms.rb new file mode 100644 index 000000000..39a87691b --- /dev/null +++ b/spec/factories/person_forms.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :person_form do + association :person + association :form + end +end diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb new file mode 100644 index 000000000..5ed83c5d2 --- /dev/null +++ b/spec/mailers/event_mailer_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe EventMailer, type: :mailer do + describe "#event_registration_confirmation" do + let(:event_registration) { create(:event_registration) } + let(:mail) { described_class.event_registration_confirmation(event_registration) } + + it "renders without raising" do + expect { mail.deliver_now }.not_to raise_error + end + + it "sends to the registrant" do + expect(mail.to).to eq([ event_registration.registrant.preferred_email ]) + end + + it "includes the event title in the subject" do + expect(mail.subject).to include(event_registration.event.title) + end + + it "includes the event title in the body" do + expect(mail.body.encoded).to include(event_registration.event.title) + end + + it "includes the registrant name in the body" do + expect(mail.body.encoded).to include(event_registration.registrant.full_name) + end + + context "when the event has a rhino_description" do + let(:event) { create(:event, rhino_description: "Join us for an art healing workshop") } + let(:event_registration) { create(:event_registration, event: event) } + + it "includes the rhino_description in the body" do + expect(mail.body.encoded).to include("Join us for an art healing workshop") + end + + it "includes the Details heading" do + expect(mail.body.encoded).to include("Details") + end + end + + context "when the event has no rhino_description" do + let(:event_registration) { create(:event_registration) } + + it "does not include the Details section" do + expect(mail.body.encoded).not_to include("Details") + end + end + end +end diff --git a/spec/models/event_registration_organization_spec.rb b/spec/models/event_registration_organization_spec.rb new file mode 100644 index 000000000..385deb7fa --- /dev/null +++ b/spec/models/event_registration_organization_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +RSpec.describe EventRegistrationOrganization, type: :model do + describe "associations" do + it { should belong_to(:event_registration).required } + it { should belong_to(:organization).required } + end + + describe "validations" do + subject { create(:event_registration_organization) } + + it { should validate_uniqueness_of(:organization_id).scoped_to(:event_registration_id) } + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 6079feacf..863bd77f0 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -7,6 +7,8 @@ it { should belong_to(:event).required } it { should belong_to(:registrant).required } it { should have_many(:comments).dependent(:destroy) } + it { should have_many(:event_registration_organizations).dependent(:destroy) } + it { should have_many(:organizations).through(:event_registration_organizations) } end describe "#active?" do @@ -259,6 +261,52 @@ end end + describe "snapshot_registrant_organizations" do + it "copies active affiliations to the registration on create" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to include(org) + end + + it "copies multiple active affiliations" do + org1 = create(:organization) + org2 = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org1) + create(:affiliation, person: person, organization: org2) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to contain_exactly(org1, org2) + end + + it "skips inactive affiliations" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org, inactive: true) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + + it "skips affiliations with past end dates" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org, end_date: 1.day.ago) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + + it "creates no records when registrant has no affiliations" do + person = create(:person) + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + end + describe "slug" do it "generates a slug on create" do registration = create(:event_registration) diff --git a/spec/models/tutorial_spec.rb b/spec/models/tutorial_spec.rb index 1872dc491..3b444536c 100644 --- a/spec/models/tutorial_spec.rb +++ b/spec/models/tutorial_spec.rb @@ -2,8 +2,8 @@ RSpec.describe Tutorial, type: :model do describe '.search_by_params' do - let!(:published_tutorial) { create(:tutorial, :published, title: 'Getting Started Guide', body: 'Welcome to AWBW') } - let!(:draft_tutorial) { create(:tutorial, title: 'Advanced Workshop Tips', body: 'For experienced facilitators') } + let!(:published_tutorial) { create(:tutorial, :published, title: 'Getting Started Guide', rhino_body: 'Welcome to AWBW') } + let!(:draft_tutorial) { create(:tutorial, title: 'Advanced Workshop Tips', rhino_body: 'For experienced facilitators') } it 'returns all when no params' do results = Tutorial.search_by_params({}) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7e992aabf..b57c8ce3b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -267,6 +267,28 @@ end end + describe "#deletable?" do + it "returns true when user has no created records" do + user = create(:user) + expect(user.deletable?).to be true + end + + it "returns false when user has created reports" do + report = create(:report, workshop: create(:workshop)) + expect(report.created_by.deletable?).to be false + end + + it "returns false when user has created workshops" do + workshop = create(:workshop) + expect(workshop.created_by.deletable?).to be false + end + + it "returns false when user has created resources" do + resource = create(:resource) + expect(resource.created_by.deletable?).to be false + end + end + describe '.search_by_params' do let!(:admin_user) { create(:user, first_name: 'Alice', last_name: 'Admin', email: 'alice@example.com', super_user: true) } let!(:regular_user) { create(:user, first_name: 'Bob', last_name: 'Regular', email: 'bob@example.com', super_user: false) } diff --git a/spec/models/workshop_log_spec.rb b/spec/models/workshop_log_spec.rb index c40d85b4f..4b2b9e5c8 100644 --- a/spec/models/workshop_log_spec.rb +++ b/spec/models/workshop_log_spec.rb @@ -9,7 +9,7 @@ describe 'associations' do # Explicitly defined here - it { should belong_to(:workshop) } + it { should belong_to(:workshop).optional } it { should belong_to(:created_by) } it { should belong_to(:organization) } # Inherited via Report but also explicit? it { should have_many(:media_files) } @@ -22,13 +22,21 @@ end describe 'validations' do - # Inherited from Report - # Add specific WorkshopLog validations if any - end + it "is valid with a workshop_id" do + workshop_log = build(:workshop_log) + expect(workshop_log).to be_valid + end - it 'is valid with valid attributes' do - # Note: Factory needs associations uncommented for create - # expect(build(:workshop_log)).to be_valid + it "is valid with external_workshop_title and no workshop" do + workshop_log = build(:workshop_log, workshop: nil, owner: nil, external_workshop_title: "Community Art Workshop") + expect(workshop_log).to be_valid + end + + it "is invalid without workshop_id or external_workshop_title" do + workshop_log = build(:workshop_log, workshop: nil, owner: nil, external_workshop_title: nil) + expect(workshop_log).not_to be_valid + expect(workshop_log.errors[:base]).to include("Please select a workshop or provide an external workshop title") + end end describe '#workshop_title' do diff --git a/spec/policies/form_policy_spec.rb b/spec/policies/form_policy_spec.rb new file mode 100644 index 000000000..694abb81a --- /dev/null +++ b/spec/policies/form_policy_spec.rb @@ -0,0 +1,40 @@ +require "rails_helper" + +RSpec.describe FormPolicy, type: :policy do + let(:admin_user) { build_stubbed :user, super_user: true } + let(:regular_user) { build_stubbed :user, super_user: false } + let(:persisted_form) { create :form } + let(:new_form) { build :form } + + def policy_for(record: nil, user:) + described_class.new(record, user: user) + end + + describe "admin user" do + subject { policy_for(record: persisted_form, user: admin_user) } + + it { is_expected.to be_allowed_to(:index?) } + it { is_expected.to be_allowed_to(:show?) } + it { is_expected.to be_allowed_to(:create?) } + it { is_expected.to be_allowed_to(:update?) } + it { is_expected.to be_allowed_to(:destroy?) } + end + + describe "regular user" do + subject { policy_for(record: persisted_form, user: regular_user) } + + it { is_expected.not_to be_allowed_to(:index?) } + it { is_expected.not_to be_allowed_to(:show?) } + it { is_expected.not_to be_allowed_to(:create?) } + it { is_expected.not_to be_allowed_to(:update?) } + it { is_expected.not_to be_allowed_to(:destroy?) } + end + + describe "#destroy?" do + context "with non-persisted record" do + subject { policy_for(record: new_form, user: admin_user) } + + it { is_expected.not_to be_allowed_to(:destroy?) } + end + end +end diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 41a49d194..faf0ae846 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -75,7 +75,7 @@ def policy_for(record: nil, user:) context "with owner" do subject { policy_for(record: owned_person, user: owner_user) } - it { is_expected.to be_allowed_to(:edit?) } + it { is_expected.not_to be_allowed_to(:edit?) } end context "with regular user who is not the owner" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 222681718..da600744f 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -49,6 +49,46 @@ expect(data_rows).to include(expected_row) end + context "registration form icon" do + let(:reg_form) { create(:form, :standalone, name: "Registration Form") } + let(:person) { existing_registration.registrant } + + it "shows green icon when person submitted the current registration form" do + create(:event_form, event: event, form: reg_form, role: "registration") + create(:person_form, person: person, form: reg_form) + + get event_registrations_path + + expect(response.body).to include("fa-solid fa-file-lines") + end + + it "shows gray icon when person has not submitted any form" do + create(:event_form, event: event, form: reg_form, role: "registration") + + get event_registrations_path + + expect(response.body).to include("fa-regular fa-file-lines") + end + + it "shows green icon when person submitted a non-registration form for the event" do + scholarship_form = create(:form, :standalone, name: "Scholarship Form") + create(:event_form, event: event, form: reg_form, role: "registration") + create(:event_form, event: event, form: scholarship_form, role: "scholarship") + create(:person_form, person: person, form: scholarship_form) + + get event_registrations_path + + expect(response.body).to include("fa-solid fa-file-lines") + expect(response.body).not_to include("fa-regular fa-file-lines") + end + + it "does not show any form icon when event has no forms" do + get event_registrations_path + + expect(response.body).not_to include("fa-file-lines") + end + end + it "paginates results" do additional = create_list(:event_registration, 3) @@ -68,7 +108,7 @@ it "creates registration and redirects admin to confirm page" do expect { post event_registrations_path, - params: { event_registration: { event_id: event.id, registrant_id: admin.person.id } } + params: { return_to: "manage", event_registration: { event_id: event.id, registrant_id: admin.person.id } } }.to change(EventRegistration, :count).by(1) registration = EventRegistration.last @@ -233,7 +273,7 @@ } }.not_to change(EventRegistration, :count) - expect(response).to redirect_to(manage_event_path(event)) + expect(response).to redirect_to(event_registrations_path) expect(flash[:alert]).to be_present end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index cd30ac332..eac574b37 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -242,4 +242,54 @@ end end end + + describe "GET /events/:id/manage" do + let(:person) { create(:person) } + let!(:registration) { create(:event_registration, event: event, registrant: person) } + + before { sign_in admin } + + context "registration form icon" do + let(:reg_form) { create(:form, :standalone, name: "Registration Form") } + + it "shows green icon when person submitted the current registration form" do + create(:event_form, event: event, form: reg_form, role: "registration") + create(:person_form, person: person, form: reg_form) + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-solid fa-file-lines') + end + + it "shows gray icon when person has not submitted any form" do + create(:event_form, event: event, form: reg_form, role: "registration") + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-regular fa-file-lines') + end + + it "shows green icon when person submitted a non-registration form for the event" do + scholarship_form = create(:form, :standalone, name: "Scholarship Form") + create(:event_form, event: event, form: reg_form, role: "registration") + create(:event_form, event: event, form: scholarship_form, role: "scholarship") + create(:person_form, person: person, form: scholarship_form) + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-solid fa-file-lines') + expect(response.body).not_to include('fa-regular fa-file-lines') + end + + it "does not show any form icon when event has no forms" do + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).not_to include('fa-file-lines') + end + end + end end diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb new file mode 100644 index 000000000..7050cba1a --- /dev/null +++ b/spec/requests/forms_spec.rb @@ -0,0 +1,194 @@ +require "rails_helper" + +RSpec.describe "/forms", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + let!(:form) { create(:form, :standalone, name: "Test Registration Form") } + + describe "GET /index" do + context "as an admin" do + before { sign_in admin } + + it "renders successfully" do + get forms_path + expect(response).to be_successful + end + + it "shows forms in the body" do + get forms_path + expect(response.body).to include("Test Registration Form") + end + end + + context "as a non-admin user" do + before { sign_in regular_user } + + it "redirects with unauthorized" do + get forms_path + expect(response).to redirect_to(root_path) + end + end + + context "as a guest" do + it "redirects to sign in" do + get forms_path + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe "GET /show" do + before { sign_in admin } + + it "renders a successful response" do + get form_url(form) + expect(response).to be_successful + expect(response.body).to include("Test Registration Form") + end + end + + describe "GET /new" do + before { sign_in admin } + + it "renders the builder selection page" do + get new_form_url + expect(response).to be_successful + expect(response.body).to include("Short Event Registration") + expect(response.body).to include("Extended Event Registration") + expect(response.body).to include("Scholarship Application") + expect(response.body).to include("Generic Form") + end + end + + describe "GET /edit" do + before { sign_in admin } + + it "renders a successful response" do + get edit_form_url(form) + expect(response).to be_successful + end + end + + describe "POST /create" do + before { sign_in admin } + + it "creates a form with the short registration builder" do + expect { + post forms_url, params: { builder_type: "short_registration" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Short Event Registration") + expect(new_form.form_fields).to be_present + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a form with the extended registration builder" do + expect { + post forms_url, params: { builder_type: "extended_registration" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Extended Event Registration") + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a form with the scholarship builder" do + expect { + post forms_url, params: { builder_type: "scholarship_application" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Scholarship Application") + expect(new_form.scholarship_application?).to be true + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a generic form with a custom name" do + expect { + post forms_url, params: { builder_type: "generic", form_name: "My Custom Form" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("My Custom Form") + expect(new_form.form_fields).to be_empty + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a generic form with default name when blank" do + post forms_url, params: { builder_type: "generic", form_name: "" } + expect(Form.last.name).to eq("New Form") + end + end + + describe "PATCH /update" do + before { sign_in admin } + + it "updates the form name" do + patch form_url(form), params: { form: { name: "Updated Form Name" } } + form.reload + expect(form.name).to eq("Updated Form Name") + expect(response).to redirect_to(form_url(form)) + end + + it "updates a nested field question label" do + field = create(:form_field, form: form, question: "Original Question") + patch form_url(form), params: { + form: { + form_fields_attributes: { + "0" => { id: field.id, question: "Updated Question" } + } + } + } + field.reload + expect(field.question).to eq("Updated Question") + end + + it "removes a field via _destroy" do + field = create(:form_field, form: form) + expect { + patch form_url(form), params: { + form: { + form_fields_attributes: { + "0" => { id: field.id, _destroy: "1" } + } + } + } + }.to change(FormField, :count).by(-1) + end + end + + describe "DELETE /destroy" do + before { sign_in admin } + + it "destroys the requested form" do + form_to_delete = create(:form, :standalone) + expect { + delete form_url(form_to_delete) + }.to change(Form, :count).by(-1) + end + + it "redirects to the forms list" do + delete form_url(form) + expect(response).to redirect_to(forms_url) + end + end + + describe "POST /add_questions" do + before { sign_in admin } + + it "clones questions from another form" do + source_form = create(:form, :standalone, name: "Source Form") + source_field = create(:form_field, form: source_form, question: "Source Q", field_key: "source_q") + + expect { + post add_questions_form_url(form), params: { source_field_ids: [ source_field.id ] } + }.to change(form.form_fields, :count).by(1) + + cloned = form.form_fields.reorder(position: :asc).last + expect(cloned.question).to eq("Source Q") + expect(cloned.field_key).to eq("source_q") + expect(response).to redirect_to(edit_form_url(form)) + end + end +end diff --git a/spec/requests/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index ebef4a307..a23238cb2 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -63,4 +63,89 @@ end end end + + describe "GET /people/:id show page content" do + context "as an admin" do + before do + sign_in admin + get person_path(other_person) + end + + it "shows the Edit link" do + expect(response.body).to include("Edit") + end + + it "shows email with admin-only styling when profile_show_email is off" do + other_person.update!(profile_show_email: false) + get person_path(other_person) + email = other_person.user&.email || other_person.email + expect(response.body).to include("admin-only") + expect(response.body).to include(email) if email.present? + end + + it "shows the Submitted content section" do + expect(response.body).to include("Submitted content") + end + + it "does not show the Comments section" do + expect(response.body).not_to include("comment_form") + end + end + + context "as the owner" do + before do + sign_in regular_user + get person_path(regular_user.person) + end + + it "does not show the Edit link" do + expect(response.body).not_to include(edit_person_path(regular_user.person)) + end + + it "shows the Submitted content section" do + expect(response.body).to include("Submitted content") + end + end + end + + describe "GET /people/:id/edit" do + context "as the owner" do + before { sign_in regular_user } + + it "redirects to root" do + get edit_person_path(regular_user.person) + expect(response).to redirect_to(root_path) + end + end + + context "as an admin" do + before { sign_in admin } + + it "renders successfully" do + get edit_person_path(other_person) + expect(response).to have_http_status(:ok) + end + end + end + + describe "PATCH /people/:id" do + context "as the owner" do + before { sign_in regular_user } + + it "redirects to root" do + patch person_path(regular_user.person), params: { person: { first_name: "Changed" } } + expect(response).to redirect_to(root_path) + end + end + + context "as an admin" do + before { sign_in admin } + + it "updates the person" do + patch person_path(other_person), params: { person: { first_name: "Updated" } } + expect(response).to redirect_to(person_path(other_person)) + expect(other_person.reload.first_name).to eq("Updated") + end + end + end end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb new file mode 100644 index 000000000..8408ad693 --- /dev/null +++ b/spec/requests/people_profile_flags_spec.rb @@ -0,0 +1,166 @@ +require "rails_helper" + +RSpec.describe "Person profile flag visibility", type: :request do + let(:admin) { create(:user, :admin) } + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + + before do + person.update!( + pronouns: "they/them", + bio: "A passionate facilitator", + linked_in_url: "https://linkedin.com/in/test" + ) + owner_user.update!(phone: "555-123-4567") + create(:affiliation, person: person, title: "Facilitator", start_date: Date.new(2020, 1, 1)) + end + + # Flags whose content is visible to any viewer with show access + { + profile_show_pronouns: "they/them", + profile_show_member_since: "Facilitator since", + profile_show_phone: "555-123-4567", + profile_show_affiliations: "Affiliations", + profile_show_sectors: "mb-3\">Sectors", + profile_show_bio: "mb-3\">Bio", + profile_show_workshops: "mb-3\">Workshops authored", + profile_show_workshop_variations: "Workshop variations authored", + profile_show_stories: "Stories authored/featured", + profile_show_events_registered: "Participation history" + }.each do |flag, marker| + describe "##{flag}" do + context "when false" do + before { person.update!(flag => false) } + + it "hides content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(marker) + end + + it "hides content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).not_to include(marker) + end + end + + context "when true" do + before { person.update!(flag => true) } + + it "shows content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(marker) + end + + it "shows content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(marker) + end + end + end + end + + describe "#profile_show_social_media" do + context "when false" do + before { person.update!(profile_show_social_media: false) } + + it "hides social media on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include("fa-linkedin-in") + end + end + + context "when true" do + before { person.update!(profile_show_social_media: true) } + + it "shows social media on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include("fa-linkedin-in") + end + + it "shows social media when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include("fa-linkedin-in") + end + end + end + + describe "#profile_show_email" do + let(:email) { person.user.email } + + context "when false" do + before { person.update!(profile_show_email: false) } + + it "hides email on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(email) + end + + it "shows email with admin-only styling when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(email) + expect(response.body).to include("admin-only bg-blue-100") + end + end + + context "when true" do + before { person.update!(profile_show_email: true) } + + it "shows email on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(email) + end + + it "shows email when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(email) + end + end + end + + # Flags inside the "Submitted content" section (gated by show? policy) + { + profile_show_workshop_ideas: "Workshop ideas submitted", + profile_show_workshop_variation_ideas: "Workshop variation ideas submitted", + profile_show_story_ideas: "Story ideas submitted", + profile_show_workshop_logs: "Workshop logs submitted" + }.each do |flag, marker| + describe "##{flag}" do + context "when false" do + before { person.update!(flag => false) } + + it "hides content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(marker) + end + end + + context "when true" do + before { person.update!(flag => true) } + + it "shows content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(marker) + end + + it "shows content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(marker) + end + end + end + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index 3b3f22dfc..767c353d3 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -286,6 +286,15 @@ delete user_url(user) expect(response).to redirect_to(users_url) end + + it "does not destroy a user with created records and shows alert" do + create(:workshop, created_by: user) + expect { + delete user_url(user) + }.not_to change(User, :count) + expect(response).to redirect_to(user_url(user)) + expect(flash[:alert]).to be_present + end end context "as regular_user" do diff --git a/spec/requests/workshop_ideas_spec.rb b/spec/requests/workshop_ideas_spec.rb index 49ceae05c..1bd7af479 100644 --- a/spec/requests/workshop_ideas_spec.rb +++ b/spec/requests/workshop_ideas_spec.rb @@ -78,6 +78,18 @@ expect(response).to redirect_to(workshop_idea_path(WorkshopIdea.last)) end + + it "handles RecordNotUnique gracefully" do + allow_any_instance_of(WorkshopIdea).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshop_ideas_path, params: { workshop_idea: valid_attributes } + }.not_to change(WorkshopIdea, :count) + + expect(response).to have_http_status(:unprocessable_content) + end end describe "PATCH /update" do @@ -90,6 +102,19 @@ expect(idea.reload.title).to eq("Updated Title") expect(response).to redirect_to(workshop_idea_path(idea)) end + + it "handles RecordNotUnique gracefully" do + idea = create(:workshop_idea, valid_attributes) + + allow_any_instance_of(WorkshopIdea).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_idea_path(idea), + params: { workshop_idea: { title: "Updated Title" } } + + expect(response).to have_http_status(:unprocessable_content) + end end describe "DELETE /destroy" do diff --git a/spec/requests/workshop_logs_spec.rb b/spec/requests/workshop_logs_spec.rb index 2f75182ff..b7c7166a2 100644 --- a/spec/requests/workshop_logs_spec.rb +++ b/spec/requests/workshop_logs_spec.rb @@ -25,6 +25,23 @@ } end + let(:external_title_attributes) do + { + date: Date.current, + workshop_id: nil, + external_workshop_title: "Community Art Workshop", + organization_id: organization.id, + windows_type_id: windows_type.id, + created_by_id: user.id, + children_first_time: 1, + children_ongoing: 2, + teens_first_time: 0, + teens_ongoing: 0, + adults_first_time: 0, + adults_ongoing: 3 + } + end + let(:invalid_attributes) do { date: nil, @@ -88,6 +105,19 @@ expect(response).to have_http_status(:redirect) end + it "creates a WorkshopLog with external_workshop_title and no workshop" do + expect { + post workshop_logs_path, params: { + workshop_log: external_title_attributes + } + }.to change(WorkshopLog, :count).by(1) + + log = WorkshopLog.last + expect(log.workshop_id).to be_nil + expect(log.external_workshop_title).to eq("Community Art Workshop") + expect(response).to have_http_status(:redirect) + end + it "creates admin and submitter notifications and enqueues mail" do expect { post workshop_logs_path, params: { diff --git a/spec/requests/workshop_variation_ideas_spec.rb b/spec/requests/workshop_variation_ideas_spec.rb index 8df894035..14a8593f0 100644 --- a/spec/requests/workshop_variation_ideas_spec.rb +++ b/spec/requests/workshop_variation_ideas_spec.rb @@ -109,6 +109,20 @@ expect(response).to have_http_status(:unprocessable_content) end end + + context "when RecordNotUnique is raised" do + it "handles it gracefully" do + allow_any_instance_of(WorkshopVariationIdea).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshop_variation_ideas_path, params: { workshop_variation_idea: valid_attributes } + }.not_to change(WorkshopVariationIdea, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end end describe "PATCH /update" do @@ -121,6 +135,19 @@ expect(idea.reload.name).to eq("Updated Name") expect(response).to redirect_to(workshop_variation_idea_path(idea)) end + + it "handles RecordNotUnique gracefully" do + idea = create(:workshop_variation_idea, valid_attributes) + + allow_any_instance_of(WorkshopVariationIdea).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_variation_idea_path(idea), + params: { workshop_variation_idea: { name: "Updated Name" } } + + expect(response).to have_http_status(:unprocessable_content) + end end describe "DELETE /destroy" do diff --git a/spec/requests/workshops_spec.rb b/spec/requests/workshops_spec.rb index 3ac3061db..254923873 100644 --- a/spec/requests/workshops_spec.rb +++ b/spec/requests/workshops_spec.rb @@ -100,4 +100,45 @@ end end end + + # --- RECORD NOT UNIQUE HANDLING ----------------------------------------------- + describe "RecordNotUnique handling" do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "POST /create" do + it "handles RecordNotUnique gracefully" do + allow_any_instance_of(Workshop).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshops_url, params: { + workshop: { title: "Test Workshop", category_ids: [ "" ] } + } + }.not_to change(Workshop, :count) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Unable to save the workshop") + end + end + + describe "PATCH /update" do + it "handles RecordNotUnique gracefully" do + workshop = create(:workshop) + + allow_any_instance_of(Workshop).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_url(workshop), params: { + workshop: { title: "Updated Title", category_ids: [ "" ] } + } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Unable to update the workshop") + end + end + end end diff --git a/spec/services/extended_event_registration_form_builder_spec.rb b/spec/services/extended_event_registration_form_builder_spec.rb new file mode 100644 index 000000000..9fd7f7e21 --- /dev/null +++ b/spec/services/extended_event_registration_form_builder_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe ExtendedEventRegistrationFormBuilder do + let(:event) { create(:event) } + + describe ".build!" do + subject(:form) { described_class.build!(event) } + + it "creates a registration form linked to the event" do + expect(form.name).to eq("Extended Event Registration") + expect(event.registration_form).to eq(form) + end + + it "assigns sequential positions starting at 1" do + field_count = form.form_fields.count + positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position) + expect(positions).to eq((1..field_count).to_a) + end + + it "inherits basic contact fields from BaseRegistrationFormBuilder" do + %w[first_name last_name primary_email confirm_email].each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + expect(field.field_group).to eq("contact") + end + end + + it "adds extended contact fields beyond the basic set" do + extended_keys = %w[nickname pronouns primary_email_type secondary_email secondary_email_type + mailing_street mailing_address_type mailing_city mailing_state mailing_zip + phone phone_type agency_name agency_position] + extended_keys.each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + end + end + + it "creates workshop_environments field with correct key" do + field = form.form_fields.find_by(field_key: "workshop_environments") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_checkbox") + end + + it "inherits consent fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "communication_consent") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_radio") + expect(field.is_required).to be true + expect(field.field_group).to eq("consent") + end + + it "inherits scholarship fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "scholarship_eligibility") + expect(field).to be_present + expect(field.field_group).to eq("scholarship") + end + + it "creates payment fields" do + attendees = form.form_fields.find_by(field_key: "number_of_attendees") + expect(attendees.answer_datatype).to eq("number_integer") + expect(attendees.is_required).to be true + + payment = form.form_fields.find_by(field_key: "payment_method") + expect(payment.answer_type).to eq("multiple_choice_radio") + end + + it "creates all expected field groups" do + groups = form.form_fields.pluck(:field_group).uniq.sort + expect(groups).to contain_exactly("background", "consent", "contact", "payment", "professional", "qualitative", "scholarship") + end + end + + describe ".build! without contact fields" do + subject(:form) { described_class.build!(event, include_contact_fields: false) } + + it "omits contact fields" do + expect(form.form_fields.find_by(field_key: "first_name")).to be_nil + expect(form.form_fields.find_by(field_key: "primary_email")).to be_nil + end + + it "still includes consent fields" do + expect(form.form_fields.find_by(field_key: "communication_consent")).to be_present + end + + it "still includes scholarship fields" do + expect(form.form_fields.find_by(field_key: "scholarship_eligibility")).to be_present + end + end + + describe ".build_standalone!" do + it "creates a form without linking to an event" do + form = described_class.build_standalone! + + expect(form.name).to eq("Extended Event Registration") + expect(form.event_forms).to be_empty + end + end + + describe ".copy!" do + it "duplicates a form for a new event" do + source_form = described_class.build!(event) + new_event = create(:event) + + copied = described_class.copy!(from_form: source_form, to_event: new_event) + + expect(copied.id).not_to eq(source_form.id) + expect(copied.form_fields.count).to eq(source_form.form_fields.count) + expect(new_event.registration_form).to eq(copied) + end + end +end diff --git a/spec/services/short_event_registration_form_builder_spec.rb b/spec/services/short_event_registration_form_builder_spec.rb new file mode 100644 index 000000000..dda53dbee --- /dev/null +++ b/spec/services/short_event_registration_form_builder_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe ShortEventRegistrationFormBuilder do + let(:event) { create(:event) } + + describe ".build!" do + subject(:form) { described_class.build!(event) } + + it "creates a registration form linked to the event" do + expect(form.name).to eq("Short Event Registration") + expect(event.registration_form).to eq(form) + end + + it "assigns sequential positions starting at 1" do + field_count = form.form_fields.count + positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position) + expect(positions).to eq((1..field_count).to_a) + end + + it "inherits basic contact fields from BaseRegistrationFormBuilder" do + %w[first_name last_name primary_email confirm_email].each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + expect(field.field_group).to eq("contact") + end + end + + it "inherits consent fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "communication_consent") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_radio") + expect(field.is_required).to be true + expect(field.field_group).to eq("consent") + end + + it "inherits scholarship fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "scholarship_eligibility") + expect(field).to be_present + expect(field.field_group).to eq("scholarship") + end + + it "creates short-specific qualitative fields" do + referral = form.form_fields.find_by(field_key: "referral_source") + expect(referral.answer_type).to eq("multiple_choice_checkbox") + expect(referral.is_required).to be true + + interest = form.form_fields.find_by(field_key: "training_interest") + expect(interest.answer_type).to eq("multiple_choice_checkbox") + expect(interest.is_required).to be true + end + end + + describe ".build_standalone!" do + it "creates a form without linking to an event" do + form = described_class.build_standalone! + + expect(form.name).to eq("Short Event Registration") + expect(form.event_forms).to be_empty + end + end +end diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb new file mode 100644 index 000000000..59a14ff42 --- /dev/null +++ b/spec/system/event_registration_edit_spec.rb @@ -0,0 +1,22 @@ +require "rails_helper" + +RSpec.describe "Event registration edit page", type: :system do + let(:admin) { create(:user, :with_person, super_user: true) } + let(:event) { create(:event, :published, title: "Test Event") } + let!(:registration) { create(:event_registration, event: event, registrant: admin.person) } + + describe "delete button" do + it "deletes the registration" do + sign_in(admin) + visit edit_event_registration_path(registration) + + accept_confirm("Are you sure you want to delete?") do + click_on "Delete" + end + + expect(page).to have_current_path(event_registrations_path) + expect(page).to have_text("Registration deleted") + expect(EventRegistration.exists?(registration.id)).to be false + end + end +end diff --git a/spec/system/navbar_avatar_updates_spec.rb b/spec/system/navbar_avatar_updates_spec.rb index 8316952db..67640c044 100644 --- a/spec/system/navbar_avatar_updates_spec.rb +++ b/spec/system/navbar_avatar_updates_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Navbar avatar behavior", type: :system do - let(:user) { create(:user) } + let(:user) { create(:user, :admin) } let!(:person) { create(:person, user: user) } before do diff --git a/spec/system/person_views_workshop_log_spec.rb b/spec/system/person_views_workshop_log_spec.rb index e68d3777b..3de119e7c 100644 --- a/spec/system/person_views_workshop_log_spec.rb +++ b/spec/system/person_views_workshop_log_spec.rb @@ -57,6 +57,67 @@ end end + describe "workshop log with external title and no workshop" do + let(:user) { create(:user) } + let!(:workshop_log) do + create(:workshop_log, + created_by: user, + organization: organization, + owner: nil, + workshop: nil, + windows_type: windows_type, + external_workshop_title: "Community Mural Project", + date: 1.day.ago) + end + + before { sign_in user } + + it "displays the external title in the heading" do + visit workshop_log_path(workshop_log) + + expect(page).to have_css("h1", text: "Community Mural Project") + end + + it "displays the external title next to the Workshop label" do + visit workshop_log_path(workshop_log) + + workshop_div = find("span", text: "Workshop:").ancestor("div", match: :first) + expect(workshop_div).to have_text("Community Mural Project") + end + end + + describe "workshop log with both workshop and external title" do + let(:user) { create(:user) } + let(:person) { create(:person, user: user) } + let!(:affiliation) { create(:affiliation, person: person, organization: organization) } + let!(:workshop_log) do + create(:workshop_log, + created_by: user, + organization: organization, + owner: workshop, + workshop: workshop, + windows_type: windows_type, + external_workshop_title: "Guest-led Session", + date: 1.day.ago) + end + + before { sign_in user } + + it "displays the workshop name in the heading" do + visit workshop_log_path(workshop_log) + + expect(page).to have_css("h1", text: "Healing Through Art") + end + + it "displays both the workshop chip and external title" do + visit workshop_log_path(workshop_log) + + workshop_div = find("span", text: "Workshop:").ancestor("div", match: :first) + expect(workshop_div).to have_text("Healing Through Art") + expect(workshop_div).to have_text("Guest-led Session") + end + end + describe "as an admin" do let(:admin) { create(:user, :admin) } let(:person) { create(:person, user: admin) } diff --git a/spec/system/workshops_spec.rb b/spec/system/workshops_spec.rb index 544d3989d..5ee3b95e7 100644 --- a/spec/system/workshops_spec.rb +++ b/spec/system/workshops_spec.rb @@ -122,7 +122,6 @@ select adult_window.short_name, from: 'workshop_windows_type_id' find("#workshop_published", visible: :all).check find('#body-button').click - fill_in 'workshop_full_name', with: 'Jane Doe' click_on 'Submit' diff --git a/spec/views/tutorials/show.html.erb_spec.rb b/spec/views/tutorials/show.html.erb_spec.rb index 54f9806d5..af95e6dd2 100644 --- a/spec/views/tutorials/show.html.erb_spec.rb +++ b/spec/views/tutorials/show.html.erb_spec.rb @@ -9,6 +9,7 @@ assign(:tutorial, Tutorial.create!( title: "Title", body: "MyText", + rhino_body: "MyText", featured: false, published: false, position: 2,