diff --git a/app/models/concerns/naturally_sortable.rb b/app/models/concerns/naturally_sortable.rb new file mode 100644 index 0000000000..e3df34a0a3 --- /dev/null +++ b/app/models/concerns/naturally_sortable.rb @@ -0,0 +1,28 @@ +# Sorts records the way a person reads a list rather than the way Postgres +# compares text: case-insensitively, and with runs of digits compared by value +# so that "Item 9" comes before "Item 10". +module NaturallySortable + extend ActiveSupport::Concern + + # Digit runs are zero padded to this width before they are compared, so any + # number shorter than this sorts by value. Longer numbers are truncated by + # `lpad` and fall back to comparing their first digits. + DIGIT_PADDING = 20 + + class_methods do + # Returns an ORDER BY expression that splits +sql_expression+ into runs of + # digits and non-digits, zero pads the digit runs and lowercases the rest. + # + # The expression is interpolated verbatim, so only pass a literal written + # here - never user input. + def natural_order(sql_expression) + Arel.sql(<<~SQL.squish) + (SELECT string_agg( + CASE WHEN chunk[1] ~ '^[0-9]' THEN lpad(chunk[1], #{DIGIT_PADDING}, '0') ELSE chunk[1] END, + '' ORDER BY idx) + FROM regexp_matches(lower(coalesce(#{sql_expression}, '')), '[0-9]+|[^0-9]+', 'g') + WITH ORDINALITY AS chunks(chunk, idx)) + SQL + end + end +end diff --git a/app/models/product_drive_participant.rb b/app/models/product_drive_participant.rb index dd2d1c656e..43c2c69a53 100644 --- a/app/models/product_drive_participant.rb +++ b/app/models/product_drive_participant.rb @@ -20,6 +20,7 @@ class ProductDriveParticipant < ApplicationRecord has_paper_trail include Filterable include Geocodable + include NaturallySortable include Provideable has_many :donations, inverse_of: :product_drive_participant, dependent: :destroy @@ -30,7 +31,7 @@ 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) } + scope :alphabetized, -> { order(natural_order("COALESCE(NULLIF(business_name, ''), contact_name)")) } 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, -> { diff --git a/app/views/donations/_donation_form.html.erb b/app/views/donations/_donation_form.html.erb index fde46484ac..b43993fdbe 100644 --- a/app/views/donations/_donation_form.html.erb +++ b/app/views/donations/_donation_form.html.erb @@ -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 %> diff --git a/app/views/donations/index.html.erb b/app/views/donations/index.html.erb index f128e67b9d..8cb16e204d 100644 --- a/app/views/donations/index.html.erb +++ b/app/views/donations/index.html.erb @@ -65,7 +65,7 @@