Skip to content
Open
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ gem "paper_trail"
gem "rolify", "~> 6.0"
# Enforces "safe" migrations.
gem "strong_migrations"
# Manages Postgres functions and triggers as versioned files.
gem "fx"
# used in events
gem 'dry-struct'
# Use solid_cache as a cache store
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ GEM
foreman (0.90.0)
thor (~> 1.4)
formatador (1.1.0)
fx (0.11.0)
activerecord (>= 7.2)
railties (>= 7.2)
geocoder (1.8.6)
base64 (>= 0.1.0)
csv (>= 3.0.0)
Expand Down Expand Up @@ -803,6 +806,7 @@ DEPENDENCIES
flipper-active_record
flipper-ui
foreman
fx
geocoder
guard-rspec
icalendar
Expand Down
17 changes: 16 additions & 1 deletion app/models/product_drive_participant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,22 @@ class ProductDriveParticipant < ApplicationRecord
validates :business_name, presence: { message: "Must provide a name or a business name" }, if: proc { |pdp| pdp.contact_name.blank? }
validates :comment, length: { maximum: 500 }

scope :alphabetized, -> { order(:contact_name) }
# Orders on the name the drop-downs actually show - `display_name`, which is
# `business_name` falling back to `contact_name` - rather than on the database
# collation, which is not the same everywhere: a `C.UTF-8` cluster puts every
# capitalised name before every lowercase one and the `en_US.utf8` image CI
# runs does not.
#
# `leading_digit_sort_key` (db/functions/leading_digit_sort_key_v01.sql, added
# via the `fx` gem) zero-pads a leading run of digits so "2" sorts before
# "10" - it does not natural-sort a digit run in the middle of the name. That
# scope was the deliberate tradeoff, over a fuller tokenizing version, agreed
# on in https://github.com/rubyforgood/human-essentials/pull/5656.
DISPLAY_NAME_ORDER = Arel.sql(
"leading_digit_sort_key(coalesce(NULLIF(business_name, ''), contact_name, ''))"
)

scope :alphabetized, -> { order(DISPLAY_NAME_ORDER) }
scope :by_business_name, ->(business_name) { where("business_name ILIKE ?", "%#{business_name}%") }
scope :by_contact_name, ->(contact_name) { where("contact_name ILIKE ?", "%#{contact_name}%") }
scope :with_volumes, -> {
Expand Down
2 changes: 1 addition & 1 deletion app/views/donations/_donation_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
collection: @product_drive_participants,
selected: donation_form.product_drive_participant_id,
include_blank: true,
label_method: lambda { |x| "#{x.try(:business_name).presence || x.try(:contact_name)}" },
label_method: :display_name,
label: "Product Drive Participant",
error: "Which product drive participant was this from?",
wrapper: :input_group %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/donations/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
<div class="form-group col-lg-3 col-md-4 col-sm-6 col-xs-12">
<%= filter_select(scope: :by_product_drive_participant,
collection: @donation_info.product_drive_participants,
value: :business_name,
value: :display_name,
selected: @donation_info.selected_product_drive_participant) %>
</div>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/product_drive_participants/create.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
$("#modal_new").modal("hide");
$("#donation_product_drive_participant_id").empty();
$("#donation_product_drive_participant_id").
html('<%= j options_from_collection_for_select(current_organization.product_drive_participants, :id, lambda { |p| p.business_name.present? ? p.business_name : p.contact_name }) %>');
html('<%= j options_from_collection_for_select(current_organization.product_drive_participants.alphabetized, :id, :display_name) %>');
$("#donation_product_drive_participant_id").append('<option value="">---Create new Participant---</option>');
$("#donation_product_drive_participant_id").val('<%= @product_drive_participant[:id] %>');
15 changes: 15 additions & 0 deletions db/functions/leading_digit_sort_key_v01.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Sort key that treats a run of digits *at the start* of the string as a
-- number instead of a sequence of characters, so "2" sorts before "10".
-- Anything past the leading digits (or the whole string, if it doesn't start
-- with a digit) is compared as plain lowercased text - a digit run elsewhere
-- in the string (e.g. "Store 9" vs. "Store 10") is not natural-sorted. That
-- narrower scope, instead of full natural sort, was the deliberate tradeoff
-- settled on in https://github.com/rubyforgood/human-essentials/pull/5656.
CREATE FUNCTION leading_digit_sort_key(value text) RETURNS text AS $$
SELECT CASE
WHEN lower(coalesce(value, '')) ~ '^[0-9]+'
THEN lpad(substring(lower(value) from '^[0-9]+'), 20, '0')
|| substring(lower(value) from '^[0-9]+(.*)$')
ELSE lower(coalesce(value, ''))
END;
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class CreateFunctionLeadingDigitSortKey < ActiveRecord::Migration[8.1]
def change
create_function :leading_digit_sort_key
end
end
17 changes: 16 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_08_29_112930) do
ActiveRecord::Schema[8.1].define(version: 2026_09_21_234706) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"

Expand Down Expand Up @@ -898,4 +898,19 @@
add_foreign_key "tags", "organizations"
add_foreign_key "units", "organizations"
add_foreign_key "users", "users_roles", column: "last_role_id", on_delete: :nullify

create_function :leading_digit_sort_key, sql_definition: <<-'SQL'
CREATE OR REPLACE FUNCTION public.leading_digit_sort_key(value text)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
AS $function$
SELECT CASE
WHEN lower(coalesce(value, '')) ~ '^[0-9]+'
THEN lpad(substring(lower(value) from '^[0-9]+'), 20, '0')
|| substring(lower(value) from '^[0-9]+(.*)$')
ELSE lower(coalesce(value, ''))
END;
$function$
SQL
end
26 changes: 26 additions & 0 deletions spec/models/product_drive_participant_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@
expect(ProductDriveParticipant.by_contact_name("Shellstrop")).to match_array([eleanor, donna])
end
end

describe ".alphabetized" do
it "orders by the name that is displayed, falling back to contact name" do
zebra = create(:product_drive_participant, business_name: "Zebra Foods", contact_name: "adam")
no_business = create(:product_drive_participant, business_name: nil, contact_name: "molly")
aardvark = create(:product_drive_participant, business_name: "Aardvark Supplies", contact_name: "zoe")

expect(ProductDriveParticipant.alphabetized).to eq([aardvark, no_business, zebra])
end

it "orders a leading number by value rather than by digit" do
tenth = create(:product_drive_participant, business_name: "10 Warehouse Way")
second = create(:product_drive_participant, business_name: "2 Warehouse Way")
ninth = create(:product_drive_participant, business_name: "9 Warehouse Way")

expect(ProductDriveParticipant.alphabetized).to eq([second, ninth, tenth])
end

it "does not natural-sort a number in the middle of the name (leading-digit only, by design - see PR #5656)" do
tenth = create(:product_drive_participant, business_name: "Store 10")
second = create(:product_drive_participant, business_name: "Store 2")
ninth = create(:product_drive_participant, business_name: "Store 9")

expect(ProductDriveParticipant.alphabetized).to eq([tenth, second, ninth])
end
end
end

context "Methods" do
Expand Down
Loading