diff --git a/Gemfile.lock b/Gemfile.lock index 2d3f618..3402f94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -465,6 +465,7 @@ PLATFORMS arm-linux-musl arm64-darwin-23 arm64-darwin-24 + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/app/javascript/controllers/one_time_amount_controller.js b/app/javascript/controllers/one_time_amount_controller.js new file mode 100644 index 0000000..a635c20 --- /dev/null +++ b/app/javascript/controllers/one_time_amount_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}) + +// Keeps a one-time giving amount within the scenario's remaining giving budget. +// Sets a custom-validity message so the browser blocks submission (via native +// constraint validation, regardless of number-input support) and shows a +// friendly error without closing the modal. +export default class extends Controller { + static values = { max: Number } + + connect() { + this.element.addEventListener("input", this.validate) + this.validate() + } + + disconnect() { + this.element.removeEventListener("input", this.validate) + } + + validate = () => { + const overMaxValue = this.element.value !== "" && Number(this.element.value) > this.maxValue + this.element.setCustomValidity(overMaxValue ? this.message : "") + } + + get message() { + return `Enter an amount of ${currency.format(this.maxValue)} or less — that's your remaining one-time giving budget.` + } +} \ No newline at end of file diff --git a/app/models/allocation/one_time.rb b/app/models/allocation/one_time.rb index 2df868a..d81eee1 100644 --- a/app/models/allocation/one_time.rb +++ b/app/models/allocation/one_time.rb @@ -19,14 +19,36 @@ def share_percentage (amount.to_i / total.to_f * 100).round end + # The most this allocation can be set to while staying within the scenario's + # total giving budget. Returns nil when no budget is set (the server imposes + # no cap there either). Never drops below the allocation's own amount so + # pre-existing over-allocated data stays editable. Used by the view helper so + # the slider cap and the server validator stay in sync. + def max_amount(scenario = self.scenario) + remaining = budget_remaining(scenario) + return if remaining.nil? + + [ remaining, amount.to_i ].max + end + private def within_total_giving_amount return if amount.blank? || scenario&.total_giving_amount.blank? - others = scenario.one_time_allocations.where.not(id: id).sum(:amount) - if others + amount > scenario.total_giving_amount + remaining = budget_remaining(scenario) + return if remaining.nil? + + if amount > remaining + others = scenario.total_giving_amount - remaining errors.add(:amount, "would bring one-time giving to #{others + amount}, over the total giving amount of #{scenario.total_giving_amount.to_i}") end end + + def budget_remaining(scenario = self.scenario) + return nil if scenario.blank? || scenario.total_giving_amount.blank? + + others = scenario.one_time_allocations.where.not(id: id).sum(:amount) + scenario.total_giving_amount - others + end end diff --git a/app/views/scenarios/_allocation_modal.html.erb b/app/views/scenarios/_allocation_modal.html.erb index b8e094f..3dfd9b8 100644 --- a/app/views/scenarios/_allocation_modal.html.erb +++ b/app/views/scenarios/_allocation_modal.html.erb @@ -26,13 +26,14 @@ class: "allocation-slider block mt-2" %> <% else %> + <% max_amount = allocation.max_amount(scenario) if klass == Allocation::OneTime %>
<%= label_tag "allocation_amount_#{suffix}", "Amount", class: "block text-sm text-ink-soft" %>
$ - <%= number_field_tag "allocation[amount]", allocation.amount, id: "allocation_amount_#{suffix}", min: 0, step: "1", required: true, placeholder: "0", + <%= number_field_tag "allocation[amount]", allocation.amount, id: "allocation_amount_#{suffix}", min: 0, max: max_amount, step: "1", required: true, placeholder: "0", inputmode: "numeric", - data: { controller: "integer-input", action: "input->integer-input#transform" }, + data: max_amount.present? ? { controller: "integer-input one-time-amount", action: "input->integer-input#transform", one_time_amount_max_value: max_amount } : { controller: "integer-input", action: "input->integer-input#transform" }, class: "block w-full rounded-md border border-line bg-surface pl-7 pr-3 py-2 shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/10" %>
diff --git a/test/controllers/allocations_controller_test.rb b/test/controllers/allocations_controller_test.rb index 7900eae..9ebd078 100644 --- a/test/controllers/allocations_controller_test.rb +++ b/test/controllers/allocations_controller_test.rb @@ -56,6 +56,14 @@ class AllocationsControllerTest < ActionDispatch::IntegrationTest assert_match Allocation::GreatestCommunityNeed::DESCRIPTION, response.body end + test "caps the one-time amount field at the remaining giving budget" do + # scenario total is 10000 and education_grant fixture already allocates 5000. + get scenario_url(@scenario) + assert_response :success + assert_match %r{id="allocation_amount_one_time"[^>]*max="5000"}, response.body + assert_match %r{data-one-time-amount-max-value="5000"}, 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 diff --git a/test/models/allocation/one_time_test.rb b/test/models/allocation/one_time_test.rb index 8b6e232..89f9212 100644 --- a/test/models/allocation/one_time_test.rb +++ b/test/models/allocation/one_time_test.rb @@ -9,4 +9,34 @@ class Allocation::OneTimeTest < ActiveSupport::TestCase allocation = Allocation::OneTime.new(amount: 500, scenario: scenarios(:two_boston)) assert_equal 0, allocation.share_percentage end + + test "max_amount is the remaining budget for a new allocation" do + scenario = scenarios(:one_arlington) + allocation = Allocation::OneTime.new + + assert_equal 5000, allocation.max_amount(scenario) + end + + test "max_amount excludes the allocation's own amount when editing" do + scenario = scenarios(:one_arlington) + allocation = allocations(:education_grant) + + assert_equal 10000, allocation.max_amount(scenario) + end + + test "max_amount never drops below the allocation's own amount" do + scenario = scenarios(:one_arlington) + scenario.update!(total_giving_amount: 4000) + allocation = allocations(:education_grant) + + assert_equal 5000, allocation.max_amount(scenario) + end + + test "max_amount is nil when no total giving amount is set" do + scenario = scenarios(:two_boston) + scenario.update!(total_giving_amount: nil) + allocation = Allocation::OneTime.new + + assert_nil allocation.max_amount(scenario) + end end