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:
parent
8aeeadd8b0
commit
e29605b79f
|
@ -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"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
#
|
#
|
||||||
|
|
|
@ -2,4 +2,5 @@
|
||||||
|
|
||||||
class SearchResultUserSerializer < BasicUserSerializer
|
class SearchResultUserSerializer < BasicUserSerializer
|
||||||
attributes :name
|
attributes :name
|
||||||
|
attributes :custom_data
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue