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(
"userField.editable",
"userField.required",
"userField.show_on_profile",
"userField.show_on_user_card"
"userField.{editable,required,show_on_profile,show_on_user_card,searchable}"
)
flags(editable, required, showOnProfile, showOnUserCard) {
flags(userField) {
const ret = [];
if (editable) {
if (userField.editable) {
ret.push(I18n.t("admin.user_fields.editable.enabled"));
}
if (required) {
if (userField.required) {
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"));
}
if (showOnUserCard) {
if (userField.showOnUserCard) {
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(", ");
},
@ -78,6 +78,7 @@ export default Component.extend(bufferedProperty("userField"), {
"required",
"show_on_profile",
"show_on_user_card",
"searchable",
"options"
);

View File

@ -22,19 +22,23 @@
{{/if}}
{{#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 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 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 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}}

View File

@ -129,6 +129,12 @@ createSearchResult({
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 = [
avatarImg("small", {
template: u.avatar_template,

View File

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

View File

@ -3,7 +3,7 @@
class Admin::UserFieldsController < Admin::AdminController
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
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 :reviewable_scores, 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_avatar, dependent: :destroy
@ -183,6 +184,9 @@ class User < ActiveRecord::Base
# set to true to optimize creation and save for imports
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
joins(:user_emails).where("lower(user_emails.email) IN (?)", email)
end
@ -1421,7 +1425,8 @@ class User < ActiveRecord::Base
end
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
def clear_global_notice_if_needed

View File

@ -2,6 +2,8 @@
class UserCustomField < ActiveRecord::Base
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
# == Schema Information

View File

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

View File

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

View File

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

View File

@ -126,12 +126,13 @@ class SearchIndexer
end
end
def self.update_users_index(user_id, username, name)
def self.update_users_index(user_id, username, name, custom_fields)
update_index(
table: 'user',
id: user_id,
a_weight: username,
b_weight: name
b_weight: name,
c_weight: custom_fields
)
end
@ -165,6 +166,16 @@ class SearchIndexer
SQL
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)
return if @disabled
@ -222,7 +233,10 @@ class SearchIndexer
end
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
if Topic === obj && (obj.saved_change_to_title? || force)

View File

@ -5008,6 +5008,10 @@ en:
title: "Show on user card?"
enabled: "shown on user card"
disabled: "not shown on user card"
searchable:
title: "Searchable?"
enabled: "searchable"
disabled: "not searchable"
field_types:
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
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)
.where(active: true)
.where(staged: false)
@ -849,7 +850,24 @@ class Search
.order("last_posted_at DESC")
.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|
user.custom_data = users_custom_data[user.id] || []
@results.add(user)
end
end

View File

@ -124,4 +124,45 @@ describe Search do
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

View File

@ -292,4 +292,23 @@ describe SearchIndexer do
)
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