Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/controllers/allocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ def update
end

def destroy
@scenario.allocations.find(params[:id]).destroy
redirect_to scenario_path(@scenario)
allocation = @scenario.allocations.find(params[:id])
if allocation.greatest_community_need?
redirect_to scenario_path(@scenario), alert: "Greatest Community Need can't be removed."
else
allocation.destroy
redirect_to scenario_path(@scenario)
end
end

private
Expand Down
4 changes: 4 additions & 0 deletions app/models/allocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ def display_label
allocation_category&.name || option
end

def greatest_community_need?
false
end

private

def category_or_option_present
Expand Down
31 changes: 31 additions & 0 deletions app/models/allocation/greatest_community_need.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class Allocation::GreatestCommunityNeed < Allocation::Ongoing
LABEL = "Greatest Community Need".freeze
DESCRIPTION =
"Funds go where local nonprofits need them most — trusted to respond to urgent, unmet needs as they arise.".freeze

after_initialize :apply_defaults

validate :only_one_per_scenario

def display_label
LABEL
end

def greatest_community_need?
true
end

private

def apply_defaults
self.option ||= LABEL
self.percentage ||= 0
end

def only_one_per_scenario
return if scenario_id.blank?

clash = self.class.where(scenario_id: scenario_id).where.not(id: id)
errors.add(:base, "Greatest Community Need has already been added") if clash.exists?
end
end
9 changes: 9 additions & 0 deletions app/models/scenario.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ class Scenario < ApplicationRecord
has_many :allocations, dependent: :destroy
has_many :ongoing_allocations, class_name: "Allocation::Ongoing"
has_many :one_time_allocations, class_name: "Allocation::OneTime"
has_one :greatest_community_need, class_name: "Allocation::GreatestCommunityNeed"

validates :name, presence: true

before_create :ensure_greatest_community_need

def ongoing_percentage_total
ongoing_allocations.sum { |allocation| allocation.percentage.to_i }
end
Expand All @@ -20,4 +23,10 @@ def one_time_giving_amount
def ongoing_giving_amount
total_giving_amount.to_i - one_time_giving_amount
end

private

def ensure_greatest_community_need
build_greatest_community_need
end
end
53 changes: 32 additions & 21 deletions app/views/scenarios/_allocation.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,41 @@
<div data-controller="allocation-slider"
data-allocation-slider-ongoing-amount-value="<%= scenario.ongoing_giving_amount %>"
data-allocation-slider-payout-rate-value="<%= Allocation::Ongoing::PERPETUITY_PAYOUT_RATE %>"
class="rounded-lg border border-line-soft bg-surface px-4 py-3 transition hover:shadow-sm">
class="rounded-lg border <%= allocation.greatest_community_need? ? "border-brand/30 bg-brand-tint" : "border-line-soft bg-surface" %> px-4 py-3 transition hover:shadow-sm">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-ink truncate"><%= allocation.display_label %></p>
<p class="mt-0.5 text-sm text-ink-soft truncate">
<%= allocation.preference_categories.map(&:name).join(", ").presence || "No additional preferences" %>
</p>
</div>
<% if allocation.greatest_community_need? %>
<div class="min-w-0">
<p class="font-medium text-ink"><%= allocation.display_label %></p>
<p class="mt-0.5 text-sm text-ink-soft"><%= Allocation::GreatestCommunityNeed::DESCRIPTION %></p>
</div>
<% else %>
<div class="min-w-0">
<p class="font-medium text-ink truncate"><%= allocation.display_label %></p>
<p class="mt-0.5 text-sm text-ink-soft truncate">
<%= allocation.preference_categories.map(&:name).join(", ").presence || "No additional preferences" %>
</p>
</div>
<% end %>
<div class="flex items-start gap-3 shrink-0">
<div class="w-16 text-right leading-tight">
<span class="block font-semibold text-ink tabular-nums" data-allocation-slider-target="percent"><%= allocation.percentage %>%</span>
<span class="block text-sm text-ink-faint tabular-nums" data-allocation-slider-target="dollar"><%= number_to_currency(allocation.dollar_amount, precision: 0) %></span>
</div>
<button type="button" data-action="dialog#open" aria-label="Edit allocation"
class="text-ink-faint hover:text-accent cursor-pointer bg-transparent p-0 leading-none">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" />
</svg>
</button>
<%= button_to scenario_allocation_path(allocation.scenario, allocation), method: :delete,
class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none",
form_class: "leading-none", aria: { label: "Remove allocation" },
data: { turbo_confirm: "Remove this allocation?" } do %>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<% unless allocation.greatest_community_need? %>
<button type="button" data-action="dialog#open" aria-label="Edit allocation"
class="text-ink-faint hover:text-accent cursor-pointer bg-transparent p-0 leading-none">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" />
</svg>
</button>
<%= button_to scenario_allocation_path(allocation.scenario, allocation), method: :delete,
class: "text-ink-faint hover:text-danger cursor-pointer bg-transparent p-0 leading-none",
form_class: "leading-none", aria: { label: "Remove allocation" },
data: { turbo_confirm: "Remove this allocation?" } do %>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<% end %>
<% end %>
</div>
</div>
Expand All @@ -56,7 +65,9 @@
</p>
</div>

<%= render "allocation_modal", allocation: allocation, scenario: scenario, color: color %>
<% unless allocation.greatest_community_need? %>
<%= render "allocation_modal", allocation: allocation, scenario: scenario, color: color %>
<% end %>
</div>
<% else %>
<div data-controller="dialog">
Expand Down
11 changes: 11 additions & 0 deletions config/initializers/sti_preload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# In production and CI, Rails eager loads every Allocation subclass for us.
# Everywhere else (development and local `bin/rails test`) eager loading is off,
# so this grandchild subclass isn't registered as a descendant when
# Scenario#ongoing_allocations builds its `type IN (...)` query and its rows get
# filtered out. Referencing it in a to_prepare block keeps it loaded.
# See https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#single-table-inheritance
unless Rails.env.production? || ENV["CI"].present?
Rails.application.config.to_prepare do
Allocation::GreatestCommunityNeed
end
end
28 changes: 27 additions & 1 deletion test/controllers/allocations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest
assert_empty allocation.reload.preference_categories
end

test "renders the always-present Greatest Community Need card" do
# one_arlington already has the greatest_need fixture allocation.
get scenario_url(@scenario)
assert_response :success
assert_match Allocation::GreatestCommunityNeed::DESCRIPTION, response.body
end

test "rejects a duplicate Greatest Community Need allocation" do
# one_arlington already has the greatest_need fixture allocation.
assert_no_difference -> { @scenario.allocations.count } do
post scenario_allocations_url(@scenario), params: {
allocation: { type: "Allocation::GreatestCommunityNeed", percentage: 20 }
}
end
assert_redirected_to scenario_path(@scenario)
assert flash[:alert].present?
end

test "creates a one time allocation" do
assert_difference -> { @scenario.allocations.count }, 1 do
post scenario_allocations_url(@scenario), params: {
Expand Down Expand Up @@ -113,9 +131,17 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest

test "destroys an allocation" do
assert_difference -> { @scenario.allocations.count }, -1 do
delete scenario_allocation_url(@scenario, allocations(:education_grant))
end
assert_redirected_to scenario_path(@scenario)
end

test "cannot delete the Greatest Community Need allocation" do
assert_no_difference -> { @scenario.allocations.count } do
delete scenario_allocation_url(@scenario, allocations(:greatest_need))
end
assert_redirected_to scenario_path(@scenario)
assert flash[:alert].present?
end

test "cannot add an allocation to a scenario you do not own" do
Expand All @@ -140,7 +166,7 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest
test "admin can destroy an allocation on another user's scenario in the same org" do
sign_in_as users(:admin)
assert_difference -> { @scenario.allocations.count }, -1 do
delete scenario_allocation_url(@scenario, allocations(:greatest_need))
delete scenario_allocation_url(@scenario, allocations(:education_grant))
end
assert_redirected_to scenario_path(@scenario)
end
Expand Down
4 changes: 2 additions & 2 deletions test/fixtures/allocations.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# greatest_need uses the free-text option fallback (no category).
# greatest_need is the dedicated Greatest Community Need STI subclass.
greatest_need:
scenario: one_arlington
type: Allocation::Ongoing
type: Allocation::GreatestCommunityNeed
option: Greatest Community Need
percentage: 30

Expand Down
25 changes: 25 additions & 0 deletions test/models/allocation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,29 @@ class AllocationTest < ActiveSupport::TestCase
assert_not allocations(:greatest_need).one_time?
assert allocations(:education_grant).one_time?
end

test "greatest_community_need? is true only for the dedicated subclass" do
assert allocations(:greatest_need).greatest_community_need?
assert_instance_of Allocation::GreatestCommunityNeed, allocations(:greatest_need)

# A plain ongoing allocation that merely shares the label is NOT the subclass.
look_alike = @scenario.ongoing_allocations.new(percentage: 10, option: "Greatest Community Need")
assert_not look_alike.greatest_community_need?
end

test "only one Greatest Community Need allocation is allowed per scenario" do
# greatest_need fixture already occupies the slot in this scenario.
duplicate = Allocation::GreatestCommunityNeed.new(scenario: @scenario)
assert_not duplicate.valid?
assert_includes duplicate.errors[:base], "Greatest Community Need has already been added"

assert Allocation::GreatestCommunityNeed.new(scenario: scenarios(:two_boston)).valid?
end

test "Greatest Community Need defaults to 0% with a fixed label and needs no category or option" do
gcn = Allocation::GreatestCommunityNeed.new(scenario: scenarios(:two_boston))
assert_equal 0, gcn.percentage
assert_equal "Greatest Community Need", gcn.display_label
assert gcn.valid?
end
end
11 changes: 11 additions & 0 deletions test/models/scenario_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ class ScenarioTest < ActiveSupport::TestCase
scenario.destroy
end
end

test "new scenarios are created with a Greatest Community Need allocation at 0%" do
scenario = users(:one).scenarios.create!(
organization: organizations(:arlington), name: "Fresh plan", total_giving_amount: 1000)

gcn = scenario.greatest_community_need
assert gcn.present?
assert gcn.greatest_community_need?
assert_equal 0, gcn.percentage
assert_includes scenario.ongoing_allocations, gcn
end
end
Loading