Merge pull request #4828 from Growstuff/alt-names

Let plantings use a crop's alternate name
This commit is contained in:
Daniel O'Connor authored and GitHub committed 2026-09-21 22:03:58 +09:30
commit c0cd7a6e60
20 files changed
+662 -550

No files matched your search

@@ -33,5 +33,5 @@ jQuery ->
el.data( 'uiAutocomplete' )._renderItem = ( ul, item ) ->
$( '<li class="list-group-item"></li>' )
.data( 'item.autocomplete', item )
.append( "<a>#{item.name}</a>" )
.append( if item.matched_alternate_name then "<a>#{item.matched_alternate_name} <small class=\"text-muted\">(#{item.name})</small></a>" else "<a>#{item.name}</a>" )
.appendTo( ul )
@@ -0,0 +1,26 @@
# When a crop is chosen in the planting form, offer that crop's alternate names.
# Searching by an alternate name ("lauki") preselects it.
jQuery ->
wrapper = $( '#planting-alternate-name' )
return unless wrapper.length
select = wrapper.find( 'select' )
crop_id = $( '#planting_crop_id' )
crop_input = $( '#crop' )
load = ( id, typed ) ->
select.empty()
return wrapper.addClass( 'd-none' ) unless id
$.getJSON wrapper.data( 'source-url' ), { crop_id: id }, ( names ) ->
select.append( $( '<option>' ).text( crop_input.val() ).val( '' ) )
for alt in names
option = $( '<option>' ).text( alt.name ).val( alt.id )
option.prop( 'selected', true ) if typed and alt.name.toLowerCase() == typed.toLowerCase()
select.append( option )
wrapper.toggleClass( 'd-none', names.length == 0 )
crop_input.on 'autocompleteselect', ( event, ui ) ->
typed = ui.item.matched_alternate_name
setTimeout ->
load( crop_id.val(), typed )
, 0
@@ -9,7 +9,9 @@ class AlternateNamesController < ApplicationController
# GET /alternate_names
# GET /alternate_names.json
def index
@alternate_names = AlternateName.all.order(:name).paginate(page: params[:page], per_page: 100)
@alternate_names = AlternateName.all.order(:name)
@alternate_names = @alternate_names.where(crop_id: params[:crop_id]) if params[:crop_id].present?
@alternate_names = @alternate_names.paginate(page: params[:page], per_page: 100)
respond_with(@alternate_names)
end
+18 -1
View File
@@ -63,7 +63,7 @@ class CropsController < ApplicationController
render
end
format.json do
render json: @crops.to_a
render json: @crops.map { |crop| crop_search_result(crop) }
end
end
end
@@ -177,6 +177,23 @@ class CropsController < ApplicationController
end.paginate(page: params[:page], per_page: 50)
end
# A search hit, plus the alternate name that matched the term when the
# crop's own name didn't ("aubergine" finds eggplant)
def crop_search_result(crop)
result = crop.as_json
return result if word_start_match?(result['name'], @term)
matched = Array(result['alternate_names']).find { |name| word_start_match?(name, @term) }
result['matched_alternate_name'] = matched if matched
result
end
def word_start_match?(name, term)
name = name.to_s.downcase
term = term.to_s.downcase.strip
name.start_with?(term) || name.split(/[\s-]+/).any? { |word| word.start_with?(term) }
end
private
def notifier
+1 -1
View File
@@ -138,7 +138,7 @@ class PlantingsController < DataController
def planting_params
params[:planted_at] = parse_date(params[:planted_at]) if params[:planted_at]
params.require(:planting).permit(
:crop_id, :description, :garden_id, :planted_at,
:crop_id, :alternate_name_id, :description, :garden_id, :planted_at,
:parent_seed_id,
:quantity, :sunniness, :planted_from, :finished,
:finished_at, :failed, :overall_rating
+1 -1
View File
@@ -35,7 +35,7 @@ module GardensHelper
plantings.each do |planting|
output += "<li>"
output += planting.quantity.nil? ? "0 " : "#{planting.quantity} "
output += link_to planting.crop.name, planting.crop
output += link_to planting.display_name, planting.crop
output += ", planted on #{planting.planted_at}</li>"
end
output += '</ul>'
+14 -1
View File
@@ -21,6 +21,7 @@ class Planting < ApplicationRecord
belongs_to :garden
belongs_to :crop, counter_cache: true
belongs_to :alternate_name, optional: true
has_many :harvests, dependent: :destroy
has_many :activities, dependent: :destroy
@@ -72,6 +73,7 @@ class Planting < ApplicationRecord
## Validations
validates :garden, presence: true
validates :crop, presence: true, approved: { message: :crop_must_be_approved }
validate :alternate_name_must_belong_to_crop
validate :finished_must_be_after_planted
validate :owner_must_match_garden_owner
validate :cannot_be_finished_and_failed
@@ -96,9 +98,14 @@ class Planting < ApplicationRecord
].join('-').tr(' ', '-').downcase
end
# The name the owner wants to see: their chosen alternate name, else the crop's primary name
def display_name
alternate_name&.name.presence || crop.name
end
# stringify as "beet in Skud's backyard" or similar
def to_s
I18n.t('plantings.string', crop: crop.name, garden: garden.name, owner:)
I18n.t('plantings.string', crop: display_name, garden: garden.name, owner:)
end
def finished?
@@ -155,4 +162,10 @@ class Planting < ApplicationRecord
errors.add(:owner, :same_owner_required)
end
def alternate_name_must_belong_to_crop
return if alternate_name.blank? || alternate_name.crop_id == crop_id
errors.add(:alternate_name, :invalid)
end
end
+2 -2
View File
@@ -5,9 +5,9 @@
= link_to planting_path(slug: planting.slug), class: 'list-group-item list-group-item-action flex-column align-items-start' do
.d-flex.w-100.justify-content-between.homepage--list-item
%p.mb-2
= image_tag planting.thumbnail_url, width: 75, class: 'rounded shadow', alt: "Image of #{planting.crop_name} by #{planting.owner_login_name}"
= image_tag planting.thumbnail_url, width: 75, class: 'rounded shadow', alt: "Image of #{planting.display_name} by #{planting.owner_login_name}"
.text-end
%h4= planting.crop_name
%h4= planting.display_name
- if planting.planted_from.present?
%span.badge.text-bg-success= planting.planted_from.pluralize
%small.text-muted planted by #{planting.owner_login_name}
+1 -1
View File
@@ -123,7 +123,7 @@
%ul
- @super_late.each do |planting|
%li
= link_to planting.crop_name, planting_url(slug: planting.slug)
= link_to planting.display_name, planting_url(slug: planting.slug)
planted on #{planting.planted_at.to_date}
- if @harvests.any?
+2 -2
View File
@@ -1,7 +1,7 @@
- cache planting do
.card.planting{class: planting.active ? '' : 'card-finished'}
= link_to planting_path(slug: planting.slug) do
= image_tag planting.thumbnail_url ? planting.thumbnail_url : placeholder_image, class: 'img-card', alt: planting.crop_name
= image_tag planting.thumbnail_url ? planting.thumbnail_url : placeholder_image, class: 'img-card', alt: planting.display_name
- if member_signed_in? && current_member.id == planting.owner_id
= link_to planting_path(slug: planting.slug) do
@@ -28,7 +28,7 @@
= delete_button(planting, classes: 'dropdown-item text-danger')
= link_to planting_path(slug: planting.slug) do
.card-body.text-center
%h4= planting.crop_name
%h4= planting.display_name
.text-center= render 'plantings/badges', planting: planting
= render 'plantings/progress', planting: planting
.card-footer
+8
View File
@@ -25,6 +25,14 @@
Can't find what you're looking for?
= link_to "Request new crops.", new_crop_path
#planting-alternate-name.mb-3{ class: ('d-none' unless @planting.crop&.alternate_names&.any?),
data: { source_url: alternate_names_path(format: :json) } }
= f.label :alternate_name_id, 'What do you call it?'
= f.select :alternate_name_id,
options_from_collection_for_select(@planting.crop&.alternate_names&.order(:name) || [], :id, :name, @planting.alternate_name_id),
{ include_blank: @planting.crop&.name || '' }, class: 'form-select'
%span.form-text Optional. Your plantings will show this name instead of the crop's main name.
.row
.col-md-8
- if @garden_locked
@@ -6,6 +6,6 @@
rel: "popover",
'data-bs-toggle': 'popover',
'data-bs-trigger': 'hover',
'data-bs-title': planting.crop.name,
'data-bs-title': planting.display_name,
'data-bs-content': render('plantings/popover', planting: planting),
'data-bs-html': true
+1 -1
View File
@@ -1,4 +1,4 @@
= link_to planting do
.chip.crop-chip
= crop_icon(planting.crop)
= planting.crop.name
= planting.display_name
+3 -3
View File
@@ -21,7 +21,7 @@ cal.description = "Plantings by #{@owner.login_name}"
event.dtstart = planting.created_at
event.dtend = finish_date || 1.day.from_now.to_date
event.summary = planting.crop.name
event.summary = planting.display_name
event.description = lines.join("\n")
event.ip_class = "PUBLIC"
event.url = planting_url(slug: planting.slug)
@@ -34,7 +34,7 @@ cal.description = "Plantings by #{@owner.login_name}"
todo = Icalendar::Todo.new
todo.dtstart = predicted_date || finish_date || Date.today
todo.due = finish_date
todo.summary = "Harvest #{planting.crop.name}"
todo.summary = "Harvest #{planting.display_name}"
cal.add_todo(todo)
@@ -42,7 +42,7 @@ cal.description = "Plantings by #{@owner.login_name}"
event = Icalendar::Event.new
event.dtstart = predicted_date || finish_date || Date.today
event.dtend = finish_date
event.summary = "Harvest #{planting.crop.name}"
event.summary = "Harvest #{planting.display_name}"
event.ip_class = "PUBLIC"
event.url = planting_url(slug: planting.slug)
+1 -1
View File
@@ -6,7 +6,7 @@
%link= plantings_url
- @plantings.each do |planting|
%item
%title #{planting.crop_name} in #{planting.location}
%title #{planting.display_name} in #{planting.location}
%pubdate= planting.created_at.to_fs(:rfc822)
%description
:escaped
+4 -2
View File
@@ -27,7 +27,7 @@
- content_for :breadcrumbs do
%li.breadcrumb-item= link_to 'Plantings', plantings_path
%li.breadcrumb-item= link_to @planting.owner, member_plantings_path(@planting.owner)
%li.breadcrumb-item.active= link_to @planting.crop.name, @planting
%li.breadcrumb-item.active= link_to @planting.display_name, @planting
- if @planting.parent_seed.nil? && @matching_seeds && @matching_seeds.any? && @planting.owner == current_member
@@ -45,8 +45,10 @@
.d-flex.justify-content-between
%h1.display-3
= crop_icon(@planting.crop)
%strong= @planting.crop.name.titleize
%strong= @planting.display_name.titleize
%small.text-muted= @planting.crop.default_scientific_name
- if @planting.alternate_name.present?
%small.text-muted= link_to "(#{@planting.crop.name})", @planting.crop
%tt
- if @planting.failed?
%span.badge.text-bg-danger Failed
@@ -0,0 +1,6 @@
class AddAlternateNameToPlantings < ActiveRecord::Migration[7.2]
def change
add_reference :plantings, :alternate_name, type: :integer, null: true, index: true,
foreign_key: { on_delete: :nullify }
end
end
+534 -531
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -94,6 +94,21 @@ describe CropsController do
it { expect(assigns(:term)).to eq 'tom' }
it { expect(assigns(:crops).map(&:name)).to eq ['tomato'] }
end
describe 'search by an alternate name' do
let!(:aubergine) { create(:alternate_eggplant) }
before do
Crop.reindex
get :search, params: { term: 'aubergine' }, format: :json
end
it 'says which alternate name matched' do
result = response.parsed_body.first
expect(result['name']).to eq 'eggplant'
expect(result['matched_alternate_name']).to eq 'aubergine'
end
end
end
end
+20
View File
@@ -337,6 +337,26 @@ describe Planting do
end
end
context 'alternate name' do
let(:alternate) { FactoryBot.create(:alternate_eggplant) }
let(:planting) { FactoryBot.build(:planting, crop: alternate.crop) }
it 'displays the crop name by default' do
expect(planting.display_name).to eq 'eggplant'
end
it 'displays the chosen alternate name' do
planting.alternate_name = alternate
expect(planting.display_name).to eq 'aubergine'
expect(planting.to_s).to include 'aubergine'
end
it 'rejects an alternate name from a different crop' do
planting.alternate_name = FactoryBot.create(:alternate_name)
expect(planting).not_to be_valid
end
end
context 'quantity' do
it 'allows integer quantities' do
@planting = build(:planting, quantity: 99)