From 28d4cc6f3f258f0e8cba548cce21a1aac8c7ea4c Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 23 Jun 2026 06:39:36 -0400 Subject: [PATCH 1/3] Add OrganizationType base-data model backing org and registration forms Replace the hardcoded "Organization Type" classification list (duplicated across the organization profile form and the public registration form builder) with an admin-managed, Publishable OrganizationType model under "Base data". Organizations now belong_to :organization_type, and the profile form, index, and show read from the association. Existing agency_type strings are backfilled onto the matching types so no data is lost; the canonical four are seeded as published. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- .../organization_types_controller.rb | 65 +++++++ app/controllers/organizations_controller.rb | 6 +- app/decorators/organization_type_decorator.rb | 9 + app/helpers/admin_cards_helper.rb | 4 + app/models/organization.rb | 1 + app/models/organization_type.rb | 37 ++++ app/policies/organization_type_policy.rb | 12 ++ app/services/form_builder_service.rb | 5 +- app/views/organization_types/_form.html.erb | 45 +++++ .../organization_types/_search_boxes.html.erb | 40 ++++ app/views/organization_types/edit.html.erb | 16 ++ app/views/organization_types/index.html.erb | 99 ++++++++++ app/views/organization_types/new.html.erb | 12 ++ app/views/organization_types/show.html.erb | 37 ++++ app/views/organizations/_form.html.erb | 13 +- .../organization_results.html.erb | 6 + app/views/organizations/show.html.erb | 5 + config/routes.rb | 1 + ...0260623103130_create_organization_types.rb | 18 ++ ..._add_organization_type_to_organizations.rb | 36 ++++ db/schema.rb | 12 ++ db/seeds.rb | 5 + lib/domain_theme.rb | 1 + spec/factories/organization_types.rb | 14 ++ spec/models/organization_type_spec.rb | 54 ++++++ spec/requests/organization_types_spec.rb | 173 ++++++++++++++++++ spec/services/form_builder_service_spec.rb | 13 ++ spec/views/page_bg_class_alignment_spec.rb | 4 + 29 files changed, 728 insertions(+), 18 deletions(-) create mode 100644 app/controllers/organization_types_controller.rb create mode 100644 app/decorators/organization_type_decorator.rb create mode 100644 app/models/organization_type.rb create mode 100644 app/policies/organization_type_policy.rb create mode 100644 app/views/organization_types/_form.html.erb create mode 100644 app/views/organization_types/_search_boxes.html.erb create mode 100644 app/views/organization_types/edit.html.erb create mode 100644 app/views/organization_types/index.html.erb create mode 100644 app/views/organization_types/new.html.erb create mode 100644 app/views/organization_types/show.html.erb create mode 100644 db/migrate/20260623103130_create_organization_types.rb create mode 100644 db/migrate/20260623103132_add_organization_type_to_organizations.rb create mode 100644 spec/factories/organization_types.rb create mode 100644 spec/models/organization_type_spec.rb create mode 100644 spec/requests/organization_types_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 0443af703b..b306c5a5d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,8 @@ This codebase (Rails 8.1) | `Story` | Editorial content with facilitators, primary/gallery assets | | `Resource` | Handouts, toolkits, templates with downloadable assets | | `Person` | Organization affiliates with contacts, addresses, sectors | -| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage | +| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage; `belongs_to :organization_type` | +| `OrganizationType` | Admin-managed "base data" classification (Publishable, e.g. "501c3/nonprofit"); supplies the organization form and public registration dropdown options | | `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount | | `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` | | `Report` | STI base class for MonthlyReport | diff --git a/app/controllers/organization_types_controller.rb b/app/controllers/organization_types_controller.rb new file mode 100644 index 0000000000..b3cc273ece --- /dev/null +++ b/app/controllers/organization_types_controller.rb @@ -0,0 +1,65 @@ +class OrganizationTypesController < ApplicationController + include AhoyTracking + before_action :set_organization_type, only: [ :show, :edit, :update, :destroy ] + + def index + authorize! + per_page = params[:number_of_items_per_page].presence || 25 + base_scope = authorized_scope(OrganizationType.all) + filtered = base_scope.filter_scope(params) + @organization_types = filtered.ordered.paginate(page: params[:page], per_page: per_page) + @organization_counts = Organization.where(organization_type_id: @organization_types.map(&:id)) + .group(:organization_type_id).count + + @count_display = filtered.count == base_scope.count ? base_scope.count : "#{filtered.count}/#{base_scope.count}" + end + + def show + authorize! @organization_type + end + + def new + @organization_type = OrganizationType.new + authorize! @organization_type + end + + def edit + authorize! @organization_type + end + + def create + @organization_type = OrganizationType.new(organization_type_params) + authorize! @organization_type + + if @organization_type.save + redirect_to @organization_type, notice: "Organization type was successfully created." + else + render :new, status: :unprocessable_content + end + end + + def update + authorize! @organization_type + if @organization_type.update(organization_type_params) + redirect_to @organization_type, notice: "Organization type was successfully updated.", status: :see_other + else + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize! @organization_type + @organization_type.destroy! + redirect_to organization_types_path, notice: "Organization type was successfully destroyed." + end + + private + + def set_organization_type + @organization_type = OrganizationType.find(params[:id]) + end + + def organization_type_params + params.require(:organization_type).permit(:name, :published, :description) + end +end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index d2a397df76..03d31204fe 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,7 +8,7 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :windows_type, :organization_status, :sectors, :addresses, + :windows_type, :organization_status, :organization_type, :sectors, :addresses, { categorizable_items: { category: :category_type } }, logo_attachment: :blob )) @@ -208,7 +208,7 @@ def populations_served def set_organization @organization = Organization.includes( - :organization_status, :windows_type, :addresses, + :organization_status, :organization_type, :windows_type, :addresses, :categorizable_items, { comments: [ :created_by, :updated_by ] }, { sectorable_items: :sector }, @@ -220,7 +220,7 @@ def set_organization def organization_params params.require(:organization).permit( :name, :description, :start_date, :end_date, :mission_vision_values, - :agency_type, :agency_type_other, :filemaker_code, :logo, :notes, :email, :website_url, + :organization_type_id, :agency_type_other, :filemaker_code, :logo, :notes, :email, :website_url, :organization_status_id, :location_id, :windows_type_id, :profile_show_sectors, :profile_show_email, :profile_show_phone, :profile_show_website, :profile_show_description, :profile_show_workshops, diff --git a/app/decorators/organization_type_decorator.rb b/app/decorators/organization_type_decorator.rb new file mode 100644 index 0000000000..a05041f755 --- /dev/null +++ b/app/decorators/organization_type_decorator.rb @@ -0,0 +1,9 @@ +class OrganizationTypeDecorator < ApplicationDecorator + def title + name + end + + def detail(length: nil) + "Organization type: #{name}" + end +end diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index 2f0dc068c7..3cdec5ab7d 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -50,6 +50,10 @@ def reference_cards intensity: 100, params: { published: true }), custom_card("Organization statuses", organization_statuses_path, icon: "🧮", color: :emerald, intensity: 100), + model_card(:organization_types, icon: "🏢", + intensity: 100, + title: "Organization types", + params: { published: true }), model_card(:sectors, icon: "🏭", intensity: 100, title: "Sectors", diff --git a/app/models/organization.rb b/app/models/organization.rb index b1ed44097d..ac9dccea2d 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,6 +1,7 @@ class Organization < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable belongs_to :organization_status + belongs_to :organization_type, optional: true belongs_to :organization_obligation, optional: true belongs_to :location, optional: true # TODO - remove Location if unused belongs_to :windows_type, optional: true diff --git a/app/models/organization_type.rb b/app/models/organization_type.rb new file mode 100644 index 0000000000..384a5dcfae --- /dev/null +++ b/app/models/organization_type.rb @@ -0,0 +1,37 @@ +class OrganizationType < ApplicationRecord + include NameFilterable, Publishable + + # Canonical classifications offered on the organization profile and public + # registration forms. Seeded as published records (see db/seeds.rb) and used + # as the fallback list when no published types exist yet (e.g. a fresh test + # database without seeds), so the forms always have sensible options. + DEFAULT_NAMES = [ + "501c3/nonprofit", + "For-profit", + "Government agency", + "Other (please specify below)" + ].freeze + + has_many :organizations, dependent: :nullify + + validates :name, presence: true, uniqueness: { case_sensitive: false } + + # Names sort cleanly: digits precede letters, so "501c3/nonprofit" leads and + # "Other (please specify below)" trails — exactly the intended display order. + scope :ordered, -> { order(:name) } + scope :name_contains, ->(term) { term.present? ? where("name LIKE ?", "%#{sanitize_sql_like(term)}%") : all } + + # Published names for the forms, falling back to the canonical defaults when + # nothing is seeded yet. + def self.published_names + published.ordered.pluck(:name).presence || DEFAULT_NAMES + end + + scope :filter_scope, ->(params) do + filtered = all + filtered = filtered.name_contains(params[:name]) + filtered = filtered.published if params[:published] == "true" + filtered = filtered.where(published: false) if params[:published] == "false" + filtered + end +end diff --git a/app/policies/organization_type_policy.rb b/app/policies/organization_type_policy.rb new file mode 100644 index 0000000000..44adbccff1 --- /dev/null +++ b/app/policies/organization_type_policy.rb @@ -0,0 +1,12 @@ +class OrganizationTypePolicy < ApplicationPolicy + def index? = admin? + def show? = admin? + def create? = admin? + def update? = admin? + def destroy? = record.persisted? && admin? + + relation_scope do |relation| + next relation if admin? + relation.none + end +end diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index daa33f5721..b65d41f8f0 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -406,10 +406,7 @@ def build_person_contact_info_fields(form, position) key: "agency_website", group: "person_contact_info", required: false) position = add_field(form, position, "Organization Type", :single_select_radio, key: "agency_type", group: "person_contact_info", required: false, - options: [ - "501c3/nonprofit", "For-profit", "Government agency", - "Other (please specify below)" - ]) + options: OrganizationType.published_names) position = add_field(form, position, "Organization Street Address", :free_form_input_one_line, key: "agency_street", group: "person_contact_info", required: false) position = add_field(form, position, "Organization City", :free_form_input_one_line, diff --git a/app/views/organization_types/_form.html.erb b/app/views/organization_types/_form.html.erb new file mode 100644 index 0000000000..bbec66f4d5 --- /dev/null +++ b/app/views/organization_types/_form.html.erb @@ -0,0 +1,45 @@ +<%= simple_form_for(@organization_type) do |f| %> +
+ + + <%= render "shared/errors", resource: @organization_type if @organization_type.errors.any? %> + + +
+ + +
+ <%= f.input :name, + label: "Name", + input_html: { class: "form-control" } %> +
+ +
+ <%= f.input :published, + as: :boolean, + wrapper_html: { class: "flex items-center gap-2" }, + input_html: { class: "form-checkbox" } %> +
+ +
+ + +
+ <%= f.input :description, + as: :text, + label: "Description", + hint: "Optional. Shown alongside this type to clarify its meaning when the name alone isn't enough.", + input_html: { class: "form-control", rows: 2 } %> +
+ +
+ <% if allowed_to?(:destroy?, f.object) %> + <%= link_to "Delete", @organization_type, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> + <% end %> + <%= link_to "Cancel", organization_types_path, class: "btn btn-secondary-outline" %> + <%= f.button :submit, class: "btn btn-primary" %> +
+ +
+<% end %> diff --git a/app/views/organization_types/_search_boxes.html.erb b/app/views/organization_types/_search_boxes.html.erb new file mode 100644 index 0000000000..2269bce840 --- /dev/null +++ b/app/views/organization_types/_search_boxes.html.erb @@ -0,0 +1,40 @@ + +
+ <%= form_with url: organization_types_path, + method: :get, + local: true, + class: "grid grid-cols-1 md:grid-cols-5 gap-4 items-end" do |f| %> + + +
+ <%= f.label :name, "Name contains", + class: "block text-sm font-medium text-gray-700" %> + + <%= f.text_field :name, + value: params[:name], + class: "mt-1 block w-full rounded-md border border-gray-300 p-2", + oninput: "this.form.requestSubmit()" %> +
+ + +
+ <%= f.label :published, "Published", + class: "block text-sm font-medium text-gray-700" %> + + <%= f.select :published, + options_for_select([["All", ""], ["Published", "true"], ["Unpublished", "false"]], params[:published]), + {}, + class: "mt-1 block w-full rounded-md border border-gray-300 p-2", + onchange: "this.form.requestSubmit()" %> +
+ + +
+ <%= link_to "Clear filters", + organization_types_path, + class: "btn btn-utility-outline whitespace-nowrap", + data: { action: "collection#clearAndSubmit" } %> +
+ + <% end %> +
diff --git a/app/views/organization_types/edit.html.erb b/app/views/organization_types/edit.html.erb new file mode 100644 index 0000000000..c44b9c14f9 --- /dev/null +++ b/app/views/organization_types/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 "Organization types", organization_types_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "View", organization_type_path(@organization_type), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+

Edit organization type

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

+ Organization types (<%= @count_display %>) +

+
+ <%= link_to "New #{OrganizationType.model_name.human.downcase}", + new_organization_type_path, + class: "admin-only bg-blue-100 btn btn-primary-outline" %> +
+
+ + <%= render "search_boxes" %> + + +
+
+ <% if @organization_types.any? %> + + + + + + + + + + + + + + + <% @organization_types.each do |organization_type| %> + + <%= organization_type.published? ? "hover:bg-gray-100" : "hover:bg-gray-50" %> transition-colors duration-150"> + + + + + + + + + + + + <% end %> + +
+ Name + + Published + + Organizations + + Actions +
+ <%= organization_type.name %> + <% if organization_type.description.present? %> + + <% end %> + + <% if organization_type.published? %> + + Yes + + <% else %> + + No + + <% end %> + + <%= @organization_counts[organization_type.id].to_i %> + + <%= link_to "Edit", + edit_organization_type_path(organization_type), + class: "btn btn-secondary-outline whitespace-nowrap" %> +
+ <% else %> + +

+ No <%= OrganizationType.model_name.human.pluralize.downcase %> found. +

+ <% end %> +
+
+ + + + +
+
diff --git a/app/views/organization_types/new.html.erb b/app/views/organization_types/new.html.erb new file mode 100644 index 0000000000..97ebdec450 --- /dev/null +++ b/app/views/organization_types/new.html.erb @@ -0,0 +1,12 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+

New organization type

+
+ +
+
+ <%= render "form", organization_type: @organization_type %> +
+
+
diff --git a/app/views/organization_types/show.html.erb b/app/views/organization_types/show.html.erb new file mode 100644 index 0000000000..7a53490ee6 --- /dev/null +++ b/app/views/organization_types/show.html.erb @@ -0,0 +1,37 @@ +<% 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 "Organization types", organization_types_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:edit?, @organization_type) %> + <%= link_to "Edit", edit_organization_type_path(@organization_type), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> +
+

+ Organization type details +

+ +
+
+
+ +
+

Name:

+

<%= @organization_type.name %>

+
+
+

Published:

+

<%= @organization_type.published? %>

+
+ +
+ + <% if @organization_type.description.present? %> +
+

Description:

+

<%= @organization_type.description %>

+
+ <% end %> +
+
+
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index f28a3f07cd..5584721d9d 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -105,19 +105,12 @@
- <%= f.input :agency_type, + <%= f.association :organization_type, label: "Organization Type", - as: :select, + collection: OrganizationType.published.ordered, + include_blank: true, required: true, - collection: [ - "501c3/nonprofit", - "For-profit", - "Government agency", - "Other (please specify below)" - ], - selected: f.object.agency_type, input_html: { - value: f.object.agency_type, class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> <%= f.input :agency_type_other, diff --git a/app/views/organizations/organization_results.html.erb b/app/views/organizations/organization_results.html.erb index 2d229d9620..1e2d70e028 100644 --- a/app/views/organizations/organization_results.html.erb +++ b/app/views/organizations/organization_results.html.erb @@ -38,6 +38,12 @@
+ <% if organization.organization_type %> + + <%= organization.organization_type.name %> + + <% end %> + <% if organization.windows_type %> <%= organization.windows_type.short_name.titleize %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 9a34053787..1db2deb548 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -52,6 +52,11 @@ <%= [address.locality, [address.city, address.state].compact_blank.join(", "), address.district].compact_blank.join(" · ") %>

<% end %> + <% if @organization.organization_type.present? %> +

+ <%= @organization.organization_type.name %> +

+ <% end %> <% org_earliest_affiliation = @organization.affiliations.minimum(:start_date) %> <% org_affiliated_since = org_earliest_affiliation || @organization.start_date %> <% org_show_aff_ended = @organization.affiliations.any? && !@organization.affiliations.active.exists? %> diff --git a/config/routes.rb b/config/routes.rb index 529c0cc97e..269cc45da0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -204,6 +204,7 @@ resources :refunds, only: [ :new, :create, :show ] resources :organization_statuses + resources :organization_types resources :affiliations, only: :destroy resources :quotes diff --git a/db/migrate/20260623103130_create_organization_types.rb b/db/migrate/20260623103130_create_organization_types.rb new file mode 100644 index 0000000000..9a1341c0b8 --- /dev/null +++ b/db/migrate/20260623103130_create_organization_types.rb @@ -0,0 +1,18 @@ +class CreateOrganizationTypes < ActiveRecord::Migration[8.0] + def up + return if table_exists?(:organization_types) + + create_table :organization_types do |t| + t.string :name + t.text :description + t.boolean :published, default: false, null: false + + t.timestamps + end + add_index :organization_types, :published + end + + def down + drop_table :organization_types, if_exists: true + end +end diff --git a/db/migrate/20260623103132_add_organization_type_to_organizations.rb b/db/migrate/20260623103132_add_organization_type_to_organizations.rb new file mode 100644 index 0000000000..bb4fd64e24 --- /dev/null +++ b/db/migrate/20260623103132_add_organization_type_to_organizations.rb @@ -0,0 +1,36 @@ +class AddOrganizationTypeToOrganizations < ActiveRecord::Migration[8.0] + # The canonical classifications previously hardcoded in the organization and + # registration forms. We seed them as published OrganizationTypes and map each + # organization's legacy `agency_type` string onto the matching record so no + # existing data is lost when the form switches to the association. + DEFAULT_NAMES = [ + "501c3/nonprofit", + "For-profit", + "Government agency", + "Other (please specify below)" + ].freeze + + def up + unless column_exists?(:organizations, :organization_type_id) + add_reference :organizations, :organization_type, foreign_key: true, null: true, index: true + end + + org_type = Class.new(ActiveRecord::Base) { self.table_name = "organization_types" } + organization = Class.new(ActiveRecord::Base) { self.table_name = "organizations" } + + DEFAULT_NAMES.each do |name| + record = org_type.where("LOWER(name) = LOWER(?)", name).first + record ||= org_type.create!(name: name) + record.update!(published: true) + end + + organization.where.not(agency_type: [ nil, "" ]).find_each do |org| + match = org_type.where("LOWER(name) = LOWER(?)", org.agency_type).first + org.update_columns(organization_type_id: match.id) if match + end + end + + def down + remove_reference :organizations, :organization_type, foreign_key: true, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9b89cc1013..e1accb7905 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -782,6 +782,15 @@ t.index ["published"], name: "index_organization_statuses_on_published" end + create_table "organization_types", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "name" + t.boolean "published", default: false, null: false + t.datetime "updated_at", null: false + t.index ["published"], name: "index_organization_types_on_published" + end + create_table "organizations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "agency_type" t.string "agency_type_other" @@ -798,6 +807,7 @@ t.string "name" t.text "notes", size: :long t.integer "organization_status_id" + t.bigint "organization_type_id" t.boolean "profile_show_description", default: true, null: false t.boolean "profile_show_email", default: true, null: false t.boolean "profile_show_events_registered", default: true, null: false @@ -813,6 +823,7 @@ t.integer "windows_type_id" t.index ["location_id"], name: "index_organizations_on_location_id" t.index ["organization_status_id"], name: "index_organizations_on_organization_status_id" + t.index ["organization_type_id"], name: "index_organizations_on_organization_type_id" t.index ["windows_type_id"], name: "index_organizations_on_windows_type_id" end @@ -1681,6 +1692,7 @@ add_foreign_key "notifications", "users", column: "sender_id" add_foreign_key "organizations", "locations" add_foreign_key "organizations", "organization_statuses" + add_foreign_key "organizations", "organization_types" add_foreign_key "organizations", "windows_types" add_foreign_key "pay_charges", "pay_customers", column: "customer_id" add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id" diff --git a/db/seeds.rb b/db/seeds.rb index 54b50151d4..d0354af9e5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -148,6 +148,11 @@ def find_or_create_by_name!(klass, name, **attrs, &block) OrganizationObligation.where(name: obligation_type).first_or_create! end +puts "Creating OrganizationTypes…" +OrganizationType::DEFAULT_NAMES.each do |type_name| + find_or_create_by_name!(OrganizationType, type_name).update!(published: true) +end + puts "Creating Sectors…" # Optional descriptions clarify a sector on the public registration form: shown as # subtext under the checkbox in the additional sectors list, and folded into diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index e291f9d101..9fc35b233f 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -11,6 +11,7 @@ module DomainTheme events: :blue, people: :sky, organizations: :emerald, + organization_types: :emerald, quotes: :slate, grants: :green, diff --git a/spec/factories/organization_types.rb b/spec/factories/organization_types.rb new file mode 100644 index 0000000000..379be8434a --- /dev/null +++ b/spec/factories/organization_types.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :organization_type do + sequence(:name) { |n| "Organization Type #{n}" } + published { false } + + trait :published do + published { true } + end + + trait :unpublished do + published { false } + end + end +end diff --git a/spec/models/organization_type_spec.rb b/spec/models/organization_type_spec.rb new file mode 100644 index 0000000000..7672ee0d57 --- /dev/null +++ b/spec/models/organization_type_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe OrganizationType, type: :model do + describe "validations" do + it "requires a name" do + expect(build(:organization_type, name: "")).not_to be_valid + end + + it "enforces case-insensitive name uniqueness" do + create(:organization_type, name: "Nonprofit") + expect(build(:organization_type, name: "nonprofit")).not_to be_valid + end + end + + describe "scopes" do + it ".published returns only published types" do + published = create(:organization_type, :published) + create(:organization_type, :unpublished) + expect(described_class.published).to contain_exactly(published) + end + + it ".name_contains filters by partial, case-insensitive match" do + match = create(:organization_type, name: "Government agency") + create(:organization_type, name: "For-profit") + expect(described_class.name_contains("govern")).to contain_exactly(match) + end + + it ".ordered sorts digits before letters so defaults read in intended order" do + OrganizationType::DEFAULT_NAMES.shuffle.each { |name| create(:organization_type, name: name) } + expect(described_class.ordered.pluck(:name)).to eq(OrganizationType::DEFAULT_NAMES) + end + end + + describe ".published_names" do + it "returns published names ordered" do + create(:organization_type, :published, name: "For-profit") + create(:organization_type, :unpublished, name: "Hidden") + expect(described_class.published_names).to eq([ "For-profit" ]) + end + + it "falls back to the canonical defaults when none are published" do + expect(described_class.published_names).to eq(OrganizationType::DEFAULT_NAMES) + end + end + + describe "associations" do + it "nullifies the type on dependent organizations when destroyed" do + type = create(:organization_type, :published) + organization = create(:organization, organization_type: type) + type.destroy + expect(organization.reload.organization_type_id).to be_nil + end + end +end diff --git a/spec/requests/organization_types_spec.rb b/spec/requests/organization_types_spec.rb new file mode 100644 index 0000000000..b2a53ce1fd --- /dev/null +++ b/spec/requests/organization_types_spec.rb @@ -0,0 +1,173 @@ +require 'rails_helper' + +RSpec.describe "/organization_types", type: :request do + let(:valid_attributes) do + { + name: "Test Organization Type", + published: true + } + end + + let(:invalid_attributes) do + { + name: "", + published: nil + } + end + + let(:admin) { create(:user, :admin) } + + before do + sign_in admin + end + + describe "GET /index" do + it "renders a successful response" do + OrganizationType.create! valid_attributes + get organization_types_url + expect(response).to be_successful + end + + context "filtering" do + let!(:organization_types) do + [ + create(:organization_type, name: "Type1", published: true), + create(:organization_type, name: "Type2", published: true), + create(:organization_type, name: "Type3", published: false), + create(:organization_type, name: "Type4", published: false) + ] + end + + it "returns all organization types without filters" do + get organization_types_url + organization_types.each { |type| expect(response.body).to include(type.name) } + end + + it "returns only published organization types when published=true" do + get organization_types_url, params: { published: "true" } + expect(response.body).to include("Type1", "Type2") + expect(response.body).not_to include("Type3") + expect(response.body).not_to include("Type4") + end + + it "returns only unpublished organization types when published=false" do + get organization_types_url, params: { published: "false" } + expect(response.body).to include("Type3", "Type4") + expect(response.body).not_to include("Type1") + expect(response.body).not_to include("Type2") + end + + it "filters by name" do + get organization_types_url, params: { name: "Type1" } + expect(response.body).to include("Type1") + expect(response.body).not_to include("Type2") + end + end + end + + describe "GET /show" do + it "renders a successful response" do + organization_type = OrganizationType.create! valid_attributes + get organization_type_url(organization_type) + expect(response).to be_successful + end + end + + describe "GET /new" do + it "renders a successful response" do + get new_organization_type_url + expect(response).to be_successful + end + end + + describe "GET /edit" do + it "renders a successful response" do + organization_type = OrganizationType.create! valid_attributes + get edit_organization_type_url(organization_type) + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new OrganizationType" do + expect { + post organization_types_url, params: { organization_type: valid_attributes } + }.to change(OrganizationType, :count).by(1) + end + + it "redirects to the created organization type" do + post organization_types_url, params: { organization_type: valid_attributes } + expect(response).to redirect_to(organization_type_url(OrganizationType.last)) + end + end + + context "with invalid parameters" do + it "does not create a new OrganizationType" do + expect { + post organization_types_url, params: { organization_type: invalid_attributes } + }.to change(OrganizationType, :count).by(0) + end + + it "renders a response with 422 status" do + post organization_types_url, params: { organization_type: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) do + valid_attributes.merge( + name: "Updated Type Name", + description: "Clarifying subtext for this type" + ) + end + + it "updates the requested organization type" do + organization_type = OrganizationType.create! valid_attributes + patch organization_type_url(organization_type), params: { organization_type: new_attributes } + organization_type.reload + expect(organization_type.name).to eq("Updated Type Name") + expect(organization_type.description).to eq("Clarifying subtext for this type") + end + + it "redirects to the organization type" do + organization_type = OrganizationType.create! valid_attributes + patch organization_type_url(organization_type), params: { organization_type: new_attributes } + expect(response).to redirect_to(organization_type_url(organization_type)) + end + end + + context "with invalid parameters" do + it "renders a response with 422 status" do + organization_type = OrganizationType.create! valid_attributes + patch organization_type_url(organization_type), params: { organization_type: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested organization type" do + organization_type = OrganizationType.create! valid_attributes + expect { + delete organization_type_url(organization_type) + }.to change(OrganizationType, :count).by(-1) + end + + it "redirects to the organization types list" do + organization_type = OrganizationType.create! valid_attributes + delete organization_type_url(organization_type) + expect(response).to redirect_to(organization_types_url) + end + + it "nullifies the type on organizations that referenced it" do + organization_type = OrganizationType.create! valid_attributes + organization = create(:organization, organization_type: organization_type) + delete organization_type_url(organization_type) + expect(organization.reload.organization_type_id).to be_nil + end + end +end diff --git a/spec/services/form_builder_service_spec.rb b/spec/services/form_builder_service_spec.rb index b6717e268c..51c7cb5d3f 100644 --- a/spec/services/form_builder_service_spec.rb +++ b/spec/services/form_builder_service_spec.rb @@ -78,6 +78,19 @@ "501c3/nonprofit", "For-profit", "Government agency", "Other (please specify below)" ) end + + context "when organization types are published" do + before do + create(:organization_type, :published, name: "Co-op") + create(:organization_type, :published, name: "Faith-based") + create(:organization_type, :unpublished, name: "Hidden") + end + + it "offers the published organization type names" do + field = form.form_fields.find_by(field_identifier: "agency_type") + expect(field.answer_options.pluck(:name)).to contain_exactly("Co-op", "Faith-based") + end + end end context "scholarship section" do diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 97efbf1652..b8ba607d70 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -119,6 +119,7 @@ "app/views/forms/show.html.erb" => "admin-only bg-blue-100", "app/views/notifications/index.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/index.html.erb" => "admin-only bg-blue-100", + "app/views/organization_types/index.html.erb" => "admin-only bg-blue-100", "app/views/quotes/index.html.erb" => "admin-only bg-blue-100", "app/views/sectors/index.html.erb" => "admin-only bg-blue-100", "app/views/story_ideas/index.html.erb" => "admin-only bg-blue-100", @@ -133,6 +134,7 @@ "app/views/categories/show.html.erb" => "admin-only bg-blue-100", "app/views/category_types/show.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/show.html.erb" => "admin-only bg-blue-100", + "app/views/organization_types/show.html.erb" => "admin-only bg-blue-100", "app/views/sectors/show.html.erb" => "admin-only bg-blue-100", "app/views/users/show.html.erb" => "admin-only bg-blue-100", "app/views/windows_types/show.html.erb" => "admin-only bg-blue-100", @@ -149,6 +151,7 @@ "app/views/forms/new.html.erb" => "admin-only bg-blue-100", "app/views/organizations/new.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/new.html.erb" => "admin-only bg-blue-100", + "app/views/organization_types/new.html.erb" => "admin-only bg-blue-100", "app/views/people/new.html.erb" => "admin-only bg-blue-100", "app/views/quotes/new.html.erb" => "admin-only bg-blue-100", "app/views/resources/new.html.erb" => "admin-only bg-blue-100", @@ -175,6 +178,7 @@ "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100", "app/views/faqs/edit.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/edit.html.erb" => "admin-only bg-blue-100", + "app/views/organization_types/edit.html.erb" => "admin-only bg-blue-100", "app/views/quotes/edit.html.erb" => "admin-only bg-blue-100", "app/views/resources/edit.html.erb" => "admin-only bg-blue-100", "app/views/sectors/edit.html.erb" => "admin-only bg-blue-100", From 0a853fb92f621766ef6aebbced0f4dde00399b95 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 23 Jun 2026 07:13:55 -0400 Subject: [PATCH 2/3] Rename the catch-all organization type to plain "Other" Drop the "(please specify below)" parenthetical from the option label. The migration backfill aliases the legacy string onto the renamed record so existing organizations keep their classification. Co-Authored-By: Claude Opus 4.8 --- app/models/organization_type.rb | 4 ++-- ...60623103132_add_organization_type_to_organizations.rb | 9 +++++++-- spec/services/form_builder_service_spec.rb | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/models/organization_type.rb b/app/models/organization_type.rb index 384a5dcfae..48ea4c3600 100644 --- a/app/models/organization_type.rb +++ b/app/models/organization_type.rb @@ -9,7 +9,7 @@ class OrganizationType < ApplicationRecord "501c3/nonprofit", "For-profit", "Government agency", - "Other (please specify below)" + "Other" ].freeze has_many :organizations, dependent: :nullify @@ -17,7 +17,7 @@ class OrganizationType < ApplicationRecord validates :name, presence: true, uniqueness: { case_sensitive: false } # Names sort cleanly: digits precede letters, so "501c3/nonprofit" leads and - # "Other (please specify below)" trails — exactly the intended display order. + # "Other" trails — exactly the intended display order. scope :ordered, -> { order(:name) } scope :name_contains, ->(term) { term.present? ? where("name LIKE ?", "%#{sanitize_sql_like(term)}%") : all } diff --git a/db/migrate/20260623103132_add_organization_type_to_organizations.rb b/db/migrate/20260623103132_add_organization_type_to_organizations.rb index bb4fd64e24..14079bfc72 100644 --- a/db/migrate/20260623103132_add_organization_type_to_organizations.rb +++ b/db/migrate/20260623103132_add_organization_type_to_organizations.rb @@ -7,9 +7,13 @@ class AddOrganizationTypeToOrganizations < ActiveRecord::Migration[8.0] "501c3/nonprofit", "For-profit", "Government agency", - "Other (please specify below)" + "Other" ].freeze + # Legacy `agency_type` strings that should map onto a renamed type, so existing + # organizations that picked the old wording still land on the right record. + LEGACY_ALIASES = { "Other (please specify below)" => "Other" }.freeze + def up unless column_exists?(:organizations, :organization_type_id) add_reference :organizations, :organization_type, foreign_key: true, null: true, index: true @@ -25,7 +29,8 @@ def up end organization.where.not(agency_type: [ nil, "" ]).find_each do |org| - match = org_type.where("LOWER(name) = LOWER(?)", org.agency_type).first + name = LEGACY_ALIASES[org.agency_type] || org.agency_type + match = org_type.where("LOWER(name) = LOWER(?)", name).first org.update_columns(organization_type_id: match.id) if match end end diff --git a/spec/services/form_builder_service_spec.rb b/spec/services/form_builder_service_spec.rb index 51c7cb5d3f..173ab66950 100644 --- a/spec/services/form_builder_service_spec.rb +++ b/spec/services/form_builder_service_spec.rb @@ -75,7 +75,7 @@ it "offers the agency type as the four organization classifications" do field = form.form_fields.find_by(field_identifier: "agency_type") expect(field.answer_options.pluck(:name)).to contain_exactly( - "501c3/nonprofit", "For-profit", "Government agency", "Other (please specify below)" + "501c3/nonprofit", "For-profit", "Government agency", "Other" ) end From b9d27211427c59cf6237999e663eddcf18e5a3fd Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 23 Jun 2026 08:02:49 -0400 Subject: [PATCH 3/3] Drop the data backfill from the organization_type migration Keep it schema-only (add the nullable reference). The canonical types are still seeded via db/seeds.rb; existing organizations are left unclassified rather than mapped from their legacy agency_type strings. Co-Authored-By: Claude Opus 4.8 --- ..._add_organization_type_to_organizations.rb | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/db/migrate/20260623103132_add_organization_type_to_organizations.rb b/db/migrate/20260623103132_add_organization_type_to_organizations.rb index 14079bfc72..7571e4db2e 100644 --- a/db/migrate/20260623103132_add_organization_type_to_organizations.rb +++ b/db/migrate/20260623103132_add_organization_type_to_organizations.rb @@ -1,38 +1,8 @@ class AddOrganizationTypeToOrganizations < ActiveRecord::Migration[8.0] - # The canonical classifications previously hardcoded in the organization and - # registration forms. We seed them as published OrganizationTypes and map each - # organization's legacy `agency_type` string onto the matching record so no - # existing data is lost when the form switches to the association. - DEFAULT_NAMES = [ - "501c3/nonprofit", - "For-profit", - "Government agency", - "Other" - ].freeze - - # Legacy `agency_type` strings that should map onto a renamed type, so existing - # organizations that picked the old wording still land on the right record. - LEGACY_ALIASES = { "Other (please specify below)" => "Other" }.freeze - def up - unless column_exists?(:organizations, :organization_type_id) - add_reference :organizations, :organization_type, foreign_key: true, null: true, index: true - end - - org_type = Class.new(ActiveRecord::Base) { self.table_name = "organization_types" } - organization = Class.new(ActiveRecord::Base) { self.table_name = "organizations" } - - DEFAULT_NAMES.each do |name| - record = org_type.where("LOWER(name) = LOWER(?)", name).first - record ||= org_type.create!(name: name) - record.update!(published: true) - end + return if column_exists?(:organizations, :organization_type_id) - organization.where.not(agency_type: [ nil, "" ]).find_each do |org| - name = LEGACY_ALIASES[org.agency_type] || org.agency_type - match = org_type.where("LOWER(name) = LOWER(?)", name).first - org.update_columns(organization_type_id: match.id) if match - end + add_reference :organizations, :organization_type, foreign_key: true, null: true, index: true end def down