From f26e98bb7bdb6d9944d3c182c6043c059bad854b Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Sat, 27 Jun 2026 22:16:58 -0500 Subject: [PATCH] Add multi-select additional preferences to allocations Allocations can now carry multiple "additional preference" categories (grouped by Program/Population/Organization) via a new allocation_preferences join table, selectable as chips in the Edit Allocation modal and surfaced beneath the category on the scenario cards. Also closes the allocation modal on backdrop click. Co-Authored-By: Claude Opus 4.8 --- app/controllers/allocations_controller.rb | 2 +- .../controllers/dialog_controller.js | 6 ++++ app/models/allocation.rb | 3 ++ app/models/allocation_category.rb | 1 + app/models/allocation_preference.rb | 4 +++ .../_additional_preferences.html.erb | 28 +++++++++++++++++++ app/views/scenarios/_allocation.html.erb | 5 +++- .../scenarios/_allocation_modal.html.erb | 4 ++- app/views/scenarios/show.html.erb | 4 +-- ...627000000_create_allocation_preferences.rb | 12 ++++++++ db/schema.rb | 14 +++++++++- db/seeds.rb | 4 ++- .../allocations_controller_test.rb | 23 +++++++++++++++ test/models/allocation_test.rb | 12 ++++++++ 14 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 app/models/allocation_preference.rb create mode 100644 app/views/scenarios/_additional_preferences.html.erb create mode 100644 db/migrate/20260627000000_create_allocation_preferences.rb diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb index d7ec421..4e7cc70 100644 --- a/app/controllers/allocations_controller.rb +++ b/app/controllers/allocations_controller.rb @@ -33,6 +33,6 @@ def set_scenario end def allocation_params - params.require(:allocation).permit(:allocation_category_id, :option, :percentage, :amount, :note, :type) + params.require(:allocation).permit(:allocation_category_id, :option, :percentage, :amount, :note, :type, preference_category_ids: []) end end diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index ea5e35b..e610f5c 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -12,4 +12,10 @@ export default class extends Controller { close() { this.dialogTarget.close() } + + backdropClose(event) { + if (event.target === this.dialogTarget) { + this.dialogTarget.close() + } + } } diff --git a/app/models/allocation.rb b/app/models/allocation.rb index e5f4f67..012867c 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -2,6 +2,9 @@ class Allocation < ApplicationRecord belongs_to :scenario belongs_to :allocation_category, optional: true + has_many :allocation_preferences, dependent: :destroy + has_many :preference_categories, through: :allocation_preferences, source: :allocation_category + validate :category_or_option_present def display_label diff --git a/app/models/allocation_category.rb b/app/models/allocation_category.rb index c4add37..da716c5 100644 --- a/app/models/allocation_category.rb +++ b/app/models/allocation_category.rb @@ -3,6 +3,7 @@ class AllocationCategory < ApplicationRecord belongs_to :parent, class_name: "AllocationCategory", optional: true has_many :children, class_name: "AllocationCategory", foreign_key: :parent_id, dependent: :destroy has_many :allocations, dependent: :nullify + has_many :allocation_preferences, dependent: :destroy validates :name, presence: true validates :type, inclusion: { in: ->(_) { TAB_CLASSES } } diff --git a/app/models/allocation_preference.rb b/app/models/allocation_preference.rb new file mode 100644 index 0000000..49235b7 --- /dev/null +++ b/app/models/allocation_preference.rb @@ -0,0 +1,4 @@ +class AllocationPreference < ApplicationRecord + belongs_to :allocation + belongs_to :allocation_category +end diff --git a/app/views/scenarios/_additional_preferences.html.erb b/app/views/scenarios/_additional_preferences.html.erb new file mode 100644 index 0000000..ed452df --- /dev/null +++ b/app/views/scenarios/_additional_preferences.html.erb @@ -0,0 +1,28 @@ +<%# locals: (allocation:, scenario:, suffix:) %> +<% roots_by_type = scenario.organization.allocation_categories + .where(parent_id: nil).order(:name).group_by(&:type) %> +<% selected_ids = allocation.preference_category_ids %> + +<% if roots_by_type.any? %> +
+ Additional preferences + + <%# Ensures the list is cleared when nothing is checked. %> + <%= hidden_field_tag "allocation[preference_category_ids][]", "" %> + + <% AllocationCategory::TAB_CLASSES.each do |klass_name| %> + <% roots = roots_by_type[klass_name] %> + <% next if roots.blank? %> +

<%= klass_name.constantize.tab_label %>

+
+ <% roots.each do |cat| %> + + <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/scenarios/_allocation.html.erb b/app/views/scenarios/_allocation.html.erb index 048f6ec..a3349f5 100644 --- a/app/views/scenarios/_allocation.html.erb +++ b/app/views/scenarios/_allocation.html.erb @@ -10,7 +10,7 @@

<%= allocation.display_label %>

- <%= allocation.note.presence || "No additional preferences" %> + <%= allocation.preference_categories.map(&:name).join(", ").presence || "No additional preferences" %>

@@ -63,6 +63,9 @@

<%= allocation.display_label %>

+ <% if allocation.preference_categories.any? %> +

<%= allocation.preference_categories.map(&:name).join(", ") %>

+ <% end %> <% if allocation.note.present? %>

<%= allocation.note %>

<% end %> diff --git a/app/views/scenarios/_allocation_modal.html.erb b/app/views/scenarios/_allocation_modal.html.erb index 6d0736c..fc53430 100644 --- a/app/views/scenarios/_allocation_modal.html.erb +++ b/app/views/scenarios/_allocation_modal.html.erb @@ -4,7 +4,7 @@ <% suffix = editing ? dom_id(allocation) : klass.model_name.element %> <% percentage = allocation.percentage || 20 %> <% color ||= ScenariosHelper::CHART_COLORS.first %> - + <%= form_with url: (editing ? scenario_allocation_path(scenario, allocation) : scenario_allocations_path(scenario)), method: (editing ? :patch : :post), class: "p-8" do |form| %> <%= hidden_field_tag "allocation[type]", klass.name %>

<%= editing ? "Edit allocation" : "Create allocation" %>

@@ -34,6 +34,8 @@
<% end %> + <%= render "scenarios/additional_preferences", allocation: allocation, scenario: scenario, suffix: suffix %> +
<%= label_tag "allocation_note_#{suffix}", "Note (optional)", class: "block text-sm text-ink-soft" %> <%= text_area_tag "allocation[note]", allocation.note, id: "allocation_note_#{suffix}", rows: 3, placeholder: "Extra preferences, restrictions, e.g. need-based scholarships only, exclude specific orgs…", diff --git a/app/views/scenarios/show.html.erb b/app/views/scenarios/show.html.erb index 67176cf..da1c914 100644 --- a/app/views/scenarios/show.html.erb +++ b/app/views/scenarios/show.html.erb @@ -26,13 +26,13 @@ <%= render "allocation_section", title: "On going giving", klass: Allocation::Ongoing, - allocations: @scenario.ongoing_allocations, + allocations: @scenario.ongoing_allocations.includes(:allocation_category, :preference_categories), scenario: @scenario %> <%= render "allocation_section", title: "One time giving", klass: Allocation::OneTime, - allocations: @scenario.one_time_allocations, + allocations: @scenario.one_time_allocations.includes(:allocation_category, :preference_categories), scenario: @scenario %>
diff --git a/db/migrate/20260627000000_create_allocation_preferences.rb b/db/migrate/20260627000000_create_allocation_preferences.rb new file mode 100644 index 0000000..77b2948 --- /dev/null +++ b/db/migrate/20260627000000_create_allocation_preferences.rb @@ -0,0 +1,12 @@ +class CreateAllocationPreferences < ActiveRecord::Migration[8.1] + def change + create_table :allocation_preferences do |t| + t.references :allocation, null: false, foreign_key: true + t.references :allocation_category, null: false, foreign_key: true + + t.timestamps + end + + add_index :allocation_preferences, [ :allocation_id, :allocation_category_id ], unique: true, name: "index_allocation_preferences_uniqueness" + end +end diff --git a/db/schema.rb b/db/schema.rb index 6956000..867a6d6 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_06_23_160001) do +ActiveRecord::Schema[8.1].define(version: 2026_06_27_000000) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -51,6 +51,16 @@ t.index ["parent_id"], name: "index_allocation_categories_on_parent_id" end + create_table "allocation_preferences", force: :cascade do |t| + t.integer "allocation_id", null: false + t.integer "allocation_category_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["allocation_category_id"], name: "index_allocation_preferences_on_allocation_category_id" + t.index ["allocation_id", "allocation_category_id"], name: "index_allocation_preferences_uniqueness", unique: true + t.index ["allocation_id"], name: "index_allocation_preferences_on_allocation_id" + end + create_table "allocations", force: :cascade do |t| t.integer "scenario_id", null: false t.string "type", null: false @@ -121,6 +131,8 @@ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "allocation_categories", "allocation_categories", column: "parent_id" add_foreign_key "allocation_categories", "organizations" + add_foreign_key "allocation_preferences", "allocation_categories" + add_foreign_key "allocation_preferences", "allocations" add_foreign_key "allocations", "allocation_categories" add_foreign_key "allocations", "scenarios" add_foreign_key "organization_memberships", "organizations" diff --git a/db/seeds.rb b/db/seeds.rb index b8265b6..e0b4444 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -71,6 +71,7 @@ education_category = arlington.allocation_categories.find_by!(name: "Education") youth_category = arlington.allocation_categories.find_by!(name: "Children and Youth") +low_income_category = arlington.allocation_categories.find_by!(name: "Low-Income") owner = User.find_by!(email_address: "owner@example.com") @@ -79,7 +80,8 @@ end if balanced.allocations.empty? - balanced.ongoing_allocations.create!(allocation_category: education_category, percentage: 30) + balanced.ongoing_allocations.create!(allocation_category: education_category, percentage: 30, + preference_categories: [ youth_category, low_income_category ]) balanced.ongoing_allocations.create!(allocation_category: youth_category, percentage: 40) # Demonstrates the free-text fallback for needs without a curated category. balanced.ongoing_allocations.create!(option: "Greatest Community Need", percentage: 30) diff --git a/test/controllers/allocations_controller_test.rb b/test/controllers/allocations_controller_test.rb index 754c8ff..7fe24f0 100644 --- a/test/controllers/allocations_controller_test.rb +++ b/test/controllers/allocations_controller_test.rb @@ -27,6 +27,29 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest assert_equal category, @scenario.allocations.order(:created_at).last.allocation_category end + test "creates an allocation with additional preferences" do + youth = allocation_categories(:population_youth) + education = allocation_categories(:program_education) + post scenario_allocations_url(@scenario), params: { + allocation: { type: "Allocation::Ongoing", option: "Mixed", percentage: 25, + preference_category_ids: [ "", youth.id, education.id ] } + } + assert_redirected_to scenario_path(@scenario) + allocation = @scenario.allocations.order(:created_at).last + assert_equal [ youth, education ].sort_by(&:id), allocation.preference_categories.sort_by(&:id) + end + + test "updating with an empty preference list clears existing preferences" do + allocation = allocations(:greatest_need) + allocation.update!(preference_category_ids: [ allocation_categories(:population_youth).id ]) + + patch scenario_allocation_url(@scenario, allocation), params: { + allocation: { preference_category_ids: [ "" ] } + } + assert_redirected_to scenario_path(@scenario) + assert_empty allocation.reload.preference_categories + end + test "creates a one time allocation" do assert_difference -> { @scenario.allocations.count }, 1 do post scenario_allocations_url(@scenario), params: { diff --git a/test/models/allocation_test.rb b/test/models/allocation_test.rb index a4dcb2c..920ddd5 100644 --- a/test/models/allocation_test.rb +++ b/test/models/allocation_test.rb @@ -47,6 +47,18 @@ class AllocationTest < ActiveSupport::TestCase assert_equal 1500, allocations(:greatest_need).dollar_amount end + test "preference_categories can be assigned and destroying the allocation removes the join rows" do + allocation = allocations(:greatest_need) + youth = allocation_categories(:population_youth) + education = allocation_categories(:program_education) + allocation.update!(preference_category_ids: [ youth.id, education.id ]) + assert_equal [ youth, education ].sort_by(&:id), allocation.preference_categories.sort_by(&:id) + + assert_difference -> { AllocationPreference.count }, -2 do + allocation.destroy + end + end + test "kind predicates reflect the subclass" do assert allocations(:greatest_need).ongoing? assert_not allocations(:greatest_need).one_time?