From 708533b1e04fd4ae974f8ac83542e95eb25118ec Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 6 Nov 2024 13:35:30 -0400 Subject: [PATCH] FEATURE: Add links to searchable user fields in users directory and user profile (#29338) * FEATURE: Add links to searchable user fields in users directory and user profile --- .../directory-item-user-field-value.gjs | 57 ++++++++++ .../app/components/user-card-contents.hbs | 12 ++- .../app/components/user-card-contents.js | 5 + .../directory-item-user-field-value.js | 13 --- .../discourse/app/templates/user.hbs | 11 +- .../discourse/tests/acceptance/users-test.js | 30 +++++- .../tests/fixtures/directory-fixtures.js | 5 +- .../stylesheets/common/base/directory.scss | 5 + app/controllers/directory_items_controller.rb | 3 + app/serializers/directory_item_serializer.rb | 21 +++- .../directory_items_controller_spec.rb | 4 +- .../directory_item_serializer_spec.rb | 100 ++++++++++++++---- 12 files changed, 227 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/directory-item-user-field-value.gjs delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js diff --git a/app/assets/javascripts/discourse/app/components/directory-item-user-field-value.gjs b/app/assets/javascripts/discourse/app/components/directory-item-user-field-value.gjs new file mode 100644 index 00000000000..ede1b8667bd --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/directory-item-user-field-value.gjs @@ -0,0 +1,57 @@ +import Component from "@glimmer/component"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { LinkTo } from "@ember/routing"; +import { inject as service } from "@ember/service"; + +export default class DirectoryItemUserFieldValueComponent extends Component { + @service router; + get fieldData() { + const { item, column } = this.args; + return item?.user?.user_fields?.[column.user_field_id]; + } + + get values() { + const fieldData = this.fieldData; + if (!fieldData || !fieldData.value) { + return null; + } + + return fieldData.value + .toString() + .split(",") + .map((v) => v.replace(/-/g, " ")) + .map((v) => v.trim()); + } + + get isSearchable() { + return this.fieldData?.searchable; + } + + @action + refreshRoute(value) { + this.router.transitionTo({ queryParams: { name: value } }); + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/user-card-contents.hbs b/app/assets/javascripts/discourse/app/components/user-card-contents.hbs index 8ac6fa2ae50..d51a9c01b31 100644 --- a/app/assets/javascripts/discourse/app/components/user-card-contents.hbs +++ b/app/assets/javascripts/discourse/app/components/user-card-contents.hbs @@ -370,7 +370,17 @@ {{#each uf.value as |v|}} {{! some values are arrays }} - {{v}} + + {{#if uf.field.searchable}} + {{v}} + {{else}} + {{v}} + {{/if}} + {{else}} {{uf.value}} {{/each}} diff --git a/app/assets/javascripts/discourse/app/components/user-card-contents.js b/app/assets/javascripts/discourse/app/components/user-card-contents.js index f529cdf8ba3..80c9fa78e9a 100644 --- a/app/assets/javascripts/discourse/app/components/user-card-contents.js +++ b/app/assets/javascripts/discourse/app/components/user-card-contents.js @@ -248,6 +248,11 @@ export default class UserCardContents extends CardContentsBase.extend( this._close(); } + @action + refreshRoute(value) { + this.router.transitionTo({ queryParams: { name: value } }); + } + @action handleShowUser(event) { if (wantsNewWindow(event)) { diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js deleted file mode 100644 index 9ea845a159f..00000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js +++ /dev/null @@ -1,13 +0,0 @@ -import { htmlSafe } from "@ember/template"; - -export default function directoryItemUserFieldValue(args) { - // Args should include key/values { item, column } - const value = - args.item.user && args.item.user.user_fields - ? args.item.user.user_fields[args.column.user_field_id] - : null; - const content = value || "-"; - return htmlSafe( - `${content}` - ); -} diff --git a/app/assets/javascripts/discourse/app/templates/user.hbs b/app/assets/javascripts/discourse/app/templates/user.hbs index 927c44ae635..632bc8de7ad 100644 --- a/app/assets/javascripts/discourse/app/templates/user.hbs +++ b/app/assets/javascripts/discourse/app/templates/user.hbs @@ -260,7 +260,16 @@ {{#each uf.value as |v|}} {{! some values are arrays }} - {{v}} + + {{#if uf.field.searchable}} + {{v}} + {{else}} + {{v}} + {{/if}} + {{else}} {{uf.value}} {{/each}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/users-test.js b/app/assets/javascripts/discourse/tests/acceptance/users-test.js index 10095882c8b..47543737508 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/users-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/users-test.js @@ -57,6 +57,34 @@ acceptance("User Directory", function () { ); }); + test("Searchable user fields display as links", async function (assert) { + pretender.get("/directory_items", () => { + return response(cloneJSON(directoryFixtures["directory_items"])); + }); + + await visit("/u"); + + const firstRowUserField = query( + ".directory .directory-table__body .directory-table__row:first-child .directory-table__value--user-field" + ); + + const userFieldLink = firstRowUserField.querySelector("a"); + + assert.ok(userFieldLink, "User field is displayed as a link"); + + assert.strictEqual( + userFieldLink.getAttribute("href"), + "/u?name=Blue&order=likes_received", + "The link points to the correct URL" + ); + + assert.strictEqual( + userFieldLink.textContent.trim(), + "Blue", + "Link text is correct" + ); + }); + test("Visit With Group Filter", async function (assert) { await visit("/u?group=trust_level_0"); assert.ok( @@ -75,7 +103,7 @@ acceptance("User Directory", function () { ".directory .directory-table__body .directory-table__row:first-child .directory-table__value--user-field" ); - assert.strictEqual(firstRowUserField.textContent, "Blue"); + assert.strictEqual(firstRowUserField.textContent.trim(), "Blue"); }); test("Can sort table via keyboard", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js index e7309ef2030..2c79cc41417 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js @@ -14,7 +14,10 @@ export default { post_count: 12263, user: { user_fields: { - 3: "Blue", + 3: { + value: ["Blue"], + searchable: true + }, }, }, }, diff --git a/app/assets/stylesheets/common/base/directory.scss b/app/assets/stylesheets/common/base/directory.scss index a5fe058a65e..85f7b052ad6 100644 --- a/app/assets/stylesheets/common/base/directory.scss +++ b/app/assets/stylesheets/common/base/directory.scss @@ -4,6 +4,11 @@ } .directory { + .directory-value-list-item:not(:empty) + ~ .directory-value-list-item:not(:empty):before { + content: "| "; + } + margin-bottom: 100px; background: var(--d-content-background); diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index fb1531c19e8..31f797fdb46 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -123,6 +123,9 @@ class DirectoryItemsController < ApplicationController end serializer_opts[:attributes] = active_directory_column_names + serializer_opts[:searchable_fields] = UserField.where(searchable: true) if serializer_opts[ + :user_custom_field_map + ].present? serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts) render_json_dump( diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index bd218f982bf..0094028dfe7 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -8,10 +8,25 @@ class DirectoryItemSerializer < ApplicationSerializer def user_fields fields = {} + user_custom_field_map = @options[:user_custom_field_map] || {} + searchable_fields = @options[:searchable_fields] || [] - object.user_custom_fields.each do |cuf| - user_field_id = @options[:user_custom_field_map][cuf.name] - fields[user_field_id] = cuf.value if user_field_id + object.user_custom_fields.each do |custom_field| + user_field_id = user_custom_field_map[custom_field.name] + next unless user_field_id + + current_value = fields.dig(user_field_id, :value) + + current_value = Array(current_value) if current_value + + new_value = current_value ? current_value << custom_field.value : custom_field.value + + is_searchable = searchable_fields.any? { |field| field.id == user_field_id } + + fields[user_field_id] = { + value: new_value.is_a?(Array) ? new_value : [new_value], + searchable: is_searchable, + } end fields diff --git a/spec/requests/directory_items_controller_spec.rb b/spec/requests/directory_items_controller_spec.rb index 361c31570c6..425edffdd39 100644 --- a/spec/requests/directory_items_controller_spec.rb +++ b/spec/requests/directory_items_controller_spec.rb @@ -248,7 +248,9 @@ RSpec.describe DirectoryItemsController do user_fields.each do |data| user = items[data[:order]]["user"] expect(user["username"]).to eq(data[:user].username) - expect(user["user_fields"]).to eq({ data[:field].id.to_s => data[:value] }) + expect(user["user_fields"]).to eq( + { data[:field].id.to_s => { "searchable" => true, "value" => [data[:value]] } }, + ) end end diff --git a/spec/serializers/directory_item_serializer_spec.rb b/spec/serializers/directory_item_serializer_spec.rb index d6cda074e23..f0b1bd2f748 100644 --- a/spec/serializers/directory_item_serializer_spec.rb +++ b/spec/serializers/directory_item_serializer_spec.rb @@ -2,32 +2,96 @@ RSpec.describe DirectoryItemSerializer do fab!(:user) + fab!(:directory_column) do + DirectoryColumn.create!(name: "topics_entered", enabled: true, position: 1) + end + fab!(:user_field_1) { Fabricate(:user_field, name: "user_field_1", searchable: true) } + fab!(:user_field_2) { Fabricate(:user_field, name: "user_field_2", searchable: false) } before { DirectoryItem.refresh! } - let :serializer do - directory_item = - DirectoryItem.find_by(user: user, period_type: DirectoryItem.period_types[:all]) - DirectoryItemSerializer.new(directory_item, { attributes: DirectoryColumn.active_column_names }) + context "when serializing user fields" do + it "serializes user fields with searchable and non-searchable values" do + user.user_custom_fields.create!(name: "user_field_1", value: "Value 1") + user.user_custom_fields.create!(name: "user_field_2", value: "Value 2") + + user_fields = + serialized_payload( + attributes: DirectoryColumn.active_column_names, + user_custom_field_map: { + "user_field_1" => user_field_1.id, + "user_field_2" => user_field_2.id, + }, + searchable_fields: [user_field_1], + ) + + expect(user_fields).to eq( + user_field_1.id => { + value: ["Value 1"], + searchable: true, + }, + user_field_2.id => { + value: ["Value 2"], + searchable: false, + }, + ) + end + + it "handles multiple values for the same field" do + user.user_custom_fields.create!(name: "user_field_1", value: "Value 1") + user.user_custom_fields.create!(name: "user_field_1", value: "Another Value") + + user_fields = + serialized_payload( + attributes: DirectoryColumn.active_column_names, + user_custom_field_map: { + "user_field_1" => user_field_1.id, + }, + searchable_fields: [], + ) + + expect(user_fields[user_field_1.id]).to eq( + value: ["Value 1", "Another Value"], + searchable: false, + ) + end end - it "Serializes attributes for enabled directory_columns" do - DirectoryColumn.update_all(enabled: true) + context "when serializing directory columns" do + let :serializer do + directory_item = + DirectoryItem.find_by(user: user, period_type: DirectoryItem.period_types[:all]) + DirectoryItemSerializer.new( + directory_item, + { attributes: DirectoryColumn.active_column_names }, + ) + end - payload = serializer.as_json - expect(payload[:directory_item].keys).to include(*DirectoryColumn.pluck(:name).map(&:to_sym)) + it "serializes attributes for enabled directory_columns" do + DirectoryColumn.update_all(enabled: true) + + payload = serializer.as_json + expect(payload[:directory_item].keys).to include(*DirectoryColumn.pluck(:name).map(&:to_sym)) + end + + it "doesn't serialize attributes for disabled directory columns" do + DirectoryColumn.update_all(enabled: false) + directory_column = DirectoryColumn.first + directory_column.update(enabled: true) + + payload = serializer.as_json + expect(payload[:directory_item].keys.count).to eq(4) + expect(payload[:directory_item]).to have_key(directory_column.name.to_sym) + expect(payload[:directory_item]).to have_key(:id) + expect(payload[:directory_item]).to have_key(:user) + expect(payload[:directory_item]).to have_key(:time_read) + end end - it "Doesn't serialize attributes for disabled directory columns" do - DirectoryColumn.update_all(enabled: false) - directory_column = DirectoryColumn.first - directory_column.update(enabled: true) + private - payload = serializer.as_json - expect(payload[:directory_item].keys.count).to eq(4) - expect(payload[:directory_item]).to have_key(directory_column.name.to_sym) - expect(payload[:directory_item]).to have_key(:id) - expect(payload[:directory_item]).to have_key(:user) - expect(payload[:directory_item]).to have_key(:time_read) + def serialized_payload(serializer_opts) + serializer = DirectoryItemSerializer.new(DirectoryItem.find_by(user: user), serializer_opts) + serializer.as_json.dig(:directory_item, :user, :user_fields) end end