<%= 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 %>
-
<% 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?