diff --git a/docs/customizing_dashboards.md b/docs/customizing_dashboards.md index c90e511b56..725560ca40 100644 --- a/docs/customizing_dashboards.md +++ b/docs/customizing_dashboards.md @@ -80,6 +80,22 @@ than one column. e.g.: `"name, email DESC"`. `:foreign_key` - Specifies the name of the foreign key directly. Defaults to `:#{attribute}_id`. +`:searchable` - Specify if the attribute should be considered when searching. +Default is `false`. + +`searchable_field` - Specify which column to use on the search, only applies if `searchable` is `true` + +For example: + +```ruby + country: Field::BelongsTo( + searchable: true, + seachable_field: 'name', + ) +``` + +with this, you will be able to search through the column `name` from the association `belongs_to :country`, from your model. + **Field::HasMany** `:limit` - Set the number of resources to display in the show view. Default is @@ -93,6 +109,22 @@ Defaults to `:#{attribute}_id`. `:foreign_key` - Specifies the name of the foreign key directly. Defaults to `:#{attribute}_id` +`:searchable` - Specify if the attribute should be considered when searching. +Default is `false`. + +`searchable_field` - Specify which column to use on the search, only applies if `searchable` is `true` + +For example: + +```ruby + cities: Field::HasMany( + searchable: true, + seachable_field: 'name', + ) +``` + +with this, you will be able to search through the column `name` from the association `has_many :cities`, from your model. + **Field::Number** `:decimals` - Set the number of decimals to display. Defaults to `0`. diff --git a/lib/administrate/field/deferred.rb b/lib/administrate/field/deferred.rb index b6e7a08cd9..af3ab13c1b 100644 --- a/lib/administrate/field/deferred.rb +++ b/lib/administrate/field/deferred.rb @@ -25,6 +25,10 @@ def searchable? options.fetch(:searchable, deferred_class.searchable?) end + def searchable_field + options.fetch(:searchable_field) + end + def permitted_attribute(attr, _options = nil) options.fetch(:foreign_key, deferred_class.permitted_attribute(attr, options)) diff --git a/lib/administrate/order.rb b/lib/administrate/order.rb index 66600be8b0..5989902c8f 100644 --- a/lib/administrate/order.rb +++ b/lib/administrate/order.rb @@ -9,7 +9,9 @@ def apply(relation) return order_by_association(relation) unless reflect_association(relation).nil? - return relation.reorder("#{attribute} #{direction}") if + order = "#{relation.table_name}.#{attribute} #{direction}" + + return relation.reorder(order) if relation.columns_hash.keys.include?(attribute.to_s) relation diff --git a/lib/administrate/search.rb b/lib/administrate/search.rb index 2b7fa453b0..14d2d86bec 100644 --- a/lib/administrate/search.rb +++ b/lib/administrate/search.rb @@ -13,7 +13,7 @@ def run if @term.blank? @scoped_resource.all else - @scoped_resource.where(query, *search_terms) + @scoped_resource.joins(tables_to_join).where(query, *search_terms) end end @@ -21,9 +21,9 @@ def run def query search_attributes.map do |attr| - table_name = ActiveRecord::Base.connection. - quote_table_name(@scoped_resource.table_name) - attr_name = ActiveRecord::Base.connection.quote_column_name(attr) + table_name = query_table_name(attr) + attr_name = column_to_query(attr) + "lower(#{table_name}.#{attr_name}) LIKE ?" end.join(" OR ") end @@ -42,6 +42,40 @@ def attribute_types @dashboard_class::ATTRIBUTE_TYPES end + def query_table_name(attr) + if association_search?(attr) + ActiveRecord::Base.connection.quote_table_name(attr.to_s.pluralize) + else + ActiveRecord::Base.connection. + quote_table_name(@scoped_resource.table_name) + end + end + + def column_to_query(attr) + if association_search?(attr) + ActiveRecord::Base.connection. + quote_column_name(attribute_types[attr].searchable_field) + else + ActiveRecord::Base.connection.quote_column_name(attr) + end + end + + def tables_to_join + attribute_types.keys.select do |attribute| + attribute_types[attribute].searchable? && association_search?(attribute) + end + end + + def association_search?(attribute) + return unless attribute_types[attribute].respond_to?(:deferred_class) + + [ + Administrate::Field::BelongsTo, + Administrate::Field::HasMany, + Administrate::Field::HasOne, + ].include?(attribute_types[attribute].deferred_class) + end + attr_reader :resolver, :term end end diff --git a/spec/example_app/app/dashboards/customer_dashboard.rb b/spec/example_app/app/dashboards/customer_dashboard.rb index fe9323a495..b89195f318 100644 --- a/spec/example_app/app/dashboards/customer_dashboard.rb +++ b/spec/example_app/app/dashboards/customer_dashboard.rb @@ -11,7 +11,12 @@ class CustomerDashboard < Administrate::BaseDashboard updated_at: Field::DateTime, kind: Field::Select.with_options(collection: Customer::KINDS), country: Field::BelongsTo. - with_options(primary_key: :code, foreign_key: :country_code), + with_options( + primary_key: :code, + foreign_key: :country_code, + searchable: true, + searchable_field: "name", + ), } COLLECTION_ATTRIBUTES = ATTRIBUTE_TYPES.keys diff --git a/spec/factories.rb b/spec/factories.rb index d2f0adfcf2..8dd4176df4 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :customer do + country sequence(:name) { |n| "Customer #{n}" } email { name.downcase.gsub(" ", "_") + "@example.com" } @@ -61,4 +62,9 @@ factory :series do sequence(:name) { |n| "Series #{n}" } end + + factory :country do + sequence(:name) { |n| "Country #{n}" } + sequence(:code) { |n| "C#{n}" } + end end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index ec3c0b2e1b..5eea06d1c8 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -42,6 +42,21 @@ expect(page).to have_content(mismatch.email) end + scenario "admin searches across associations fields", :js do + country = create(:country, name: "Brazil", code: "BR") + country_match = create(:customer, country: country) + mismatch = create(:customer) + + visit admin_customers_path + + fill_in :search, with: "Brazil" + submit_search + + expect(page).to have_content(country_match.email) + expect(page).to have_content(country.name) + expect(page).not_to have_content(mismatch.email) + end + def clear_search find(".search__clear-link").click end diff --git a/spec/lib/administrate/order_spec.rb b/spec/lib/administrate/order_spec.rb index 2ae5eee76b..afdabc7638 100644 --- a/spec/lib/administrate/order_spec.rb +++ b/spec/lib/administrate/order_spec.rb @@ -36,7 +36,7 @@ ordered = order.apply(relation) - expect(relation).to have_received(:reorder).with("name asc") + expect(relation).to have_received(:reorder).with("table_name.name asc") expect(ordered).to eq(relation) end @@ -47,7 +47,7 @@ ordered = order.apply(relation) - expect(relation).to have_received(:reorder).with("name desc") + expect(relation).to have_received(:reorder).with("table_name.name desc") expect(ordered).to eq(relation) end end @@ -171,6 +171,7 @@ def relation_with_column(column) double( klass: double(reflect_on_association: nil), columns_hash: { column.to_s => :column_info }, + table_name: "table_name", ) end diff --git a/spec/lib/administrate/search_spec.rb b/spec/lib/administrate/search_spec.rb index 9785ca5483..6642e7c738 100644 --- a/spec/lib/administrate/search_spec.rb +++ b/spec/lib/administrate/search_spec.rb @@ -10,7 +10,20 @@ class MockDashboard name: Administrate::Field::String, email: Administrate::Field::Email, phone: Administrate::Field::Number, - } + }.freeze +end + +class MockDashboardWithAssociation + ATTRIBUTE_TYPES = { + role: Administrate::Field::BelongsTo.with_options( + searchable: true, + searchable_field: "name", + ), + address: Administrate::Field::HasOne.with_options( + searchable: true, + searchable_field: "street", + ), + }.freeze end describe Administrate::Search do @@ -86,5 +99,44 @@ class User < ActiveRecord::Base; end remove_constants :User end end + + context "when searching through associations" do + let(:scoped_object) { double(:scoped_object) } + + let(:search) do + Administrate::Search.new( + scoped_object, + MockDashboardWithAssociation, + "Тест Test", + ) + end + + let(:expected_query) do + [ + "lower(\"roles\".\"name\") LIKE ?"\ + " OR lower(\"addresses\".\"street\") LIKE ?", + "%тест test%", + "%тест test%", + ] + end + + it "joins with the correct association table to query" do + allow(scoped_object).to receive(:where) + + expect(scoped_object).to receive(:joins).with(%i(role address)). + and_return(scoped_object) + + search.run + end + + it "builds the 'where' clause using the joined tables" do + allow(scoped_object).to receive(:joins).with(%i(role address)). + and_return(scoped_object) + + expect(scoped_object).to receive(:where).with(*expected_query) + + search.run + end + end end end