FEATURE: the ability to search users by custom fields (#12762)

When the admin creates a new custom field they can specify if that field should be searchable or not.

That setting is taken into consideration for quick search results.
This commit is contained in:
Krzysztof Kotlarek 2021-04-27 15:52:45 +10:00 committed by GitHub
parent 8aeeadd8b0
commit e29605b79f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 154 additions and 19 deletions

View File

@ -44,25 +44,25 @@ export default Component.extend(bufferedProperty("userField"), {
}, },
@discourseComputed( @discourseComputed(
"userField.editable", "userField.{editable,required,show_on_profile,show_on_user_card,searchable}"
"userField.required",
"userField.show_on_profile",
"userField.show_on_user_card"
) )
flags(editable, required, showOnProfile, showOnUserCard) { flags(userField) {
const ret = []; const ret = [];
if (editable) { if (userField.editable) {
ret.push(I18n.t("admin.user_fields.editable.enabled")); ret.push(I18n.t("admin.user_fields.editable.enabled"));
} }
if (required) { if (userField.required) {
ret.push(I18n.t("admin.user_fields.required.enabled")); ret.push(I18n.t("admin.user_fields.required.enabled"));
} }
if (showOnProfile) { if (userField.showOnProfile) {
ret.push(I18n.t("admin.user_fields.show_on_profile.enabled")); ret.push(I18n.t("admin.user_fields.show_on_profile.enabled"));
} }
if (showOnUserCard) { if (userField.showOnUserCard) {
ret.push(I18n.t("admin.user_fields.show_on_user_card.enabled")); ret.push(I18n.t("admin.user_fields.show_on_user_card.enabled"));
} }
if (userField.searchable) {
ret.push(I18n.t("admin.user_fields.searchable.enabled"));
}
return ret.join(", "); return ret.join(", ");
}, },
@ -78,6 +78,7 @@ export default Component.extend(bufferedProperty("userField"), {
"required", "required",
"show_on_profile", "show_on_profile",
"show_on_user_card", "show_on_user_card",
"searchable",
"options" "options"
); );

View File

@ -22,19 +22,23 @@
{{/if}} {{/if}}
{{#admin-form-row wrapLabel="true"}} {{#admin-form-row wrapLabel="true"}}
{{input type="checkbox" checked=buffered.editable}} {{i18n "admin.user_fields.editable.title"}} {{input type="checkbox" checked=buffered.editable}} <span>{{i18n "admin.user_fields.editable.title"}}</span>
{{/admin-form-row}} {{/admin-form-row}}
{{#admin-form-row wrapLabel="true"}} {{#admin-form-row wrapLabel="true"}}
{{input type="checkbox" checked=buffered.required}} {{i18n "admin.user_fields.required.title"}} {{input type="checkbox" checked=buffered.required}} <span>{{i18n "admin.user_fields.required.title"}}</span>
{{/admin-form-row}} {{/admin-form-row}}
{{#admin-form-row wrapLabel="true"}} {{#admin-form-row wrapLabel="true"}}
{{input type="checkbox" checked=buffered.show_on_profile}} {{i18n "admin.user_fields.show_on_profile.title"}} {{input type="checkbox" checked=buffered.show_on_profile}} <span>{{i18n "admin.user_fields.show_on_profile.title"}}</span>
{{/admin-form-row}} {{/admin-form-row}}
{{#admin-form-row wrapLabel="true"}} {{#admin-form-row wrapLabel="true"}}
{{input type="checkbox" checked=buffered.show_on_user_card}} {{i18n "admin.user_fields.show_on_user_card.title"}} {{input type="checkbox" checked=buffered.show_on_user_card}} <span>{{i18n "admin.user_fields.show_on_user_card.title"}}</span>
{{/admin-form-row}}
{{#admin-form-row wrapLabel="true"}}
{{input type="checkbox" checked=buffered.searchable}} <span>{{i18n "admin.user_fields.searchable.title"}}</span>
{{/admin-form-row}} {{/admin-form-row}}
{{#admin-form-row}} {{#admin-form-row}}

View File

@ -129,6 +129,12 @@ createSearchResult({
userTitles.push(h("span.username", formatUsername(u.username))); userTitles.push(h("span.username", formatUsername(u.username)));
if (u.custom_data) {
u.custom_data.forEach((row) =>
userTitles.push(h("span.custom-field", `${row.name}: ${row.value}`))
);
}
const userResultContents = [ const userResultContents = [
avatarImg("small", { avatarImg("small", {
template: u.avatar_template, template: u.avatar_template,

View File

@ -222,6 +222,11 @@
font-size: $font-down-1; font-size: $font-down-1;
} }
.custom-field {
color: var(--primary-high-or-secondary-low);
font-size: $font-down-2;
}
.name { .name {
color: var(--primary-high-or-secondary-low); color: var(--primary-high-or-secondary-low);
font-size: $font-0; font-size: $font-0;

View File

@ -3,7 +3,7 @@
class Admin::UserFieldsController < Admin::AdminController class Admin::UserFieldsController < Admin::AdminController
def self.columns def self.columns
[:name, :field_type, :editable, :description, :required, :show_on_profile, :show_on_user_card, :position] %i(name field_type editable description required show_on_profile show_on_user_card position searchable)
end end
def create def create

View File

@ -37,6 +37,7 @@ class User < ActiveRecord::Base
has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: 'GroupHistory' has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: 'GroupHistory'
has_many :reviewable_scores, dependent: :destroy has_many :reviewable_scores, dependent: :destroy
has_many :invites, foreign_key: :invited_by_id, dependent: :destroy has_many :invites, foreign_key: :invited_by_id, dependent: :destroy
has_many :user_custom_fields, dependent: :destroy
has_one :user_option, dependent: :destroy has_one :user_option, dependent: :destroy
has_one :user_avatar, dependent: :destroy has_one :user_avatar, dependent: :destroy
@ -183,6 +184,9 @@ class User < ActiveRecord::Base
# set to true to optimize creation and save for imports # set to true to optimize creation and save for imports
attr_accessor :import_mode attr_accessor :import_mode
# Cache for user custom fields. Currently it is used to display quick search results
attr_accessor :custom_data
scope :with_email, ->(email) do scope :with_email, ->(email) do
joins(:user_emails).where("lower(user_emails.email) IN (?)", email) joins(:user_emails).where("lower(user_emails.email) IN (?)", email)
end end
@ -1421,7 +1425,8 @@ class User < ActiveRecord::Base
end end
def index_search def index_search
SearchIndexer.index(self) # force is needed as user custom fields are updated using SQL and after_save callback is not triggered
SearchIndexer.index(self, force: true)
end end
def clear_global_notice_if_needed def clear_global_notice_if_needed

View File

@ -2,6 +2,8 @@
class UserCustomField < ActiveRecord::Base class UserCustomField < ActiveRecord::Base
belongs_to :user belongs_to :user
scope :searchable, -> { joins("INNER JOIN user_fields ON user_fields.id = REPLACE(user_custom_fields.name, 'user_field_', '')::INTEGER AND user_fields.searchable IS TRUE AND user_custom_fields.name like 'user_field_%'") }
end end
# == Schema Information # == Schema Information

View File

@ -9,9 +9,15 @@ class UserField < ActiveRecord::Base
has_many :user_field_options, dependent: :destroy has_many :user_field_options, dependent: :destroy
accepts_nested_attributes_for :user_field_options accepts_nested_attributes_for :user_field_options
after_save :queue_index_search
def self.max_length def self.max_length
2048 2048
end end
def queue_index_search
SearchIndexer.queue_users_reindex(UserCustomField.where(name: "user_field_#{self.id}").pluck(:user_id))
end
end end
# == Schema Information # == Schema Information
@ -31,4 +37,5 @@ end
# show_on_user_card :boolean default(FALSE), not null # show_on_user_card :boolean default(FALSE), not null
# external_name :string # external_name :string
# external_type :string # external_type :string
# searchable :boolean default(FALSE), not null
# #

View File

@ -2,4 +2,5 @@
class SearchResultUserSerializer < BasicUserSerializer class SearchResultUserSerializer < BasicUserSerializer
attributes :name attributes :name
attributes :custom_data
end end

View File

@ -9,6 +9,7 @@ class UserFieldSerializer < ApplicationSerializer
:required, :required,
:show_on_profile, :show_on_profile,
:show_on_user_card, :show_on_user_card,
:searchable,
:position, :position,
:options :options

View File

@ -126,12 +126,13 @@ class SearchIndexer
end end
end end
def self.update_users_index(user_id, username, name) def self.update_users_index(user_id, username, name, custom_fields)
update_index( update_index(
table: 'user', table: 'user',
id: user_id, id: user_id,
a_weight: username, a_weight: username,
b_weight: name b_weight: name,
c_weight: custom_fields
) )
end end
@ -165,6 +166,16 @@ class SearchIndexer
SQL SQL
end end
def self.queue_users_reindex(user_ids)
return if @disabled
DB.exec(<<~SQL, user_ids: user_ids, version: REINDEX_VERSION)
UPDATE user_search_data
SET version = :version
WHERE user_search_data.user_id IN (:user_ids)
SQL
end
def self.queue_post_reindex(topic_id) def self.queue_post_reindex(topic_id)
return if @disabled return if @disabled
@ -222,7 +233,10 @@ class SearchIndexer
end end
if User === obj && (obj.saved_change_to_username? || obj.saved_change_to_name? || force) if User === obj && (obj.saved_change_to_username? || obj.saved_change_to_name? || force)
SearchIndexer.update_users_index(obj.id, obj.username_lower || '', obj.name ? obj.name.downcase : '') SearchIndexer.update_users_index(obj.id,
obj.username_lower || '',
obj.name ? obj.name.downcase : '',
obj.user_custom_fields.searchable.map(&:value).join(" "))
end end
if Topic === obj && (obj.saved_change_to_title? || force) if Topic === obj && (obj.saved_change_to_title? || force)

View File

@ -5008,6 +5008,10 @@ en:
title: "Show on user card?" title: "Show on user card?"
enabled: "shown on user card" enabled: "shown on user card"
disabled: "not shown on user card" disabled: "not shown on user card"
searchable:
title: "Searchable?"
enabled: "searchable"
disabled: "not searchable"
field_types: field_types:
text: "Text Field" text: "Text Field"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSearchableToUserFields < ActiveRecord::Migration[6.0]
def change
add_column :user_fields, :searchable, :boolean, default: :false, null: false
end
end

View File

@ -840,7 +840,8 @@ class Search
def user_search def user_search
return if SiteSetting.hide_user_profiles_from_public && !@guardian.user return if SiteSetting.hide_user_profiles_from_public && !@guardian.user
users = User.includes(:user_search_data) users = User
.includes(:user_search_data)
.references(:user_search_data) .references(:user_search_data)
.where(active: true) .where(active: true)
.where(staged: false) .where(staged: false)
@ -849,7 +850,24 @@ class Search
.order("last_posted_at DESC") .order("last_posted_at DESC")
.limit(limit) .limit(limit)
users_custom_data_query = DB.query(<<~SQL, user_ids: users.pluck(:id), term: "%#{@original_term.downcase}%")
SELECT user_custom_fields.user_id, user_fields.name, user_custom_fields.value FROM user_custom_fields
INNER JOIN user_fields ON user_fields.id = REPLACE(user_custom_fields.name, 'user_field_', '')::INTEGER AND user_fields.searchable IS TRUE
WHERE user_id IN (:user_ids)
AND user_custom_fields.name LIKE 'user_field_%'
AND user_custom_fields.value ILIKE :term
SQL
users_custom_data = users_custom_data_query.reduce({}) do |acc, row|
acc[row.user_id] =
Array.wrap(acc[row.user_id]) << {
name: row.name,
value: row.value
}
acc
end
users.each do |user| users.each do |user|
user.custom_data = users_custom_data[user.id] || []
@results.add(user) @results.add(user)
end end
end end

View File

@ -124,4 +124,45 @@ describe Search do
end end
end end
end end
context "users" do
fab!(:user) { Fabricate(:user, username: "DonaldDuck") }
fab!(:user2) { Fabricate(:user) }
before do
SearchIndexer.enable
SearchIndexer.index(user, force: true)
end
it "finds users by their names or custom fields" do
result = Search.execute("donaldduck", guardian: Guardian.new(user2))
expect(result.users).to contain_exactly(user)
user_field = Fabricate(:user_field, name: "custom field")
UserCustomField.create!(user: user, value: "test", name: "user_field_#{user_field.id}")
Jobs::ReindexSearch.new.execute({})
result = Search.execute("test", guardian: Guardian.new(user2))
expect(result.users).to be_empty
user_field.update!(searchable: true)
Jobs::ReindexSearch.new.execute({})
result = Search.execute("test", guardian: Guardian.new(user2))
expect(result.users).to contain_exactly(user)
user_field2 = Fabricate(:user_field, name: "another custom field", searchable: true)
UserCustomField.create!(user: user, value: "longer test", name: "user_field_#{user_field2.id}")
UserCustomField.create!(user: user2, value: "second user test", name: "user_field_#{user_field2.id}")
SearchIndexer.index(user, force: true)
SearchIndexer.index(user2, force: true)
result = Search.execute("test", guardian: Guardian.new(user2))
expect(result.users.first.custom_data).to eq([
{ name: "custom field", value: "test" },
{ name: "another custom field", value: "longer test" }
])
expect(result.users.last.custom_data).to eq([
{ name: "another custom field", value: "second user test" }
])
end
end
end end

View File

@ -292,4 +292,23 @@ describe SearchIndexer do
) )
end end
end end
describe '.queue_users_reindex' do
let!(:user) { Fabricate(:user) }
let!(:user2) { Fabricate(:user) }
it 'should reset the version of search data for all users' do
SearchIndexer.index(user, force: true)
SearchIndexer.index(user2, force: true)
SearchIndexer.queue_users_reindex([user.id])
expect(user.reload.user_search_data.version).to eq(
SearchIndexer::REINDEX_VERSION
)
expect(user2.reload.user_search_data.version).to eq(
SearchIndexer::USER_INDEX_VERSION
)
end
end
end end