From e6e62860d9d4ba6619a6d657d5632b4579aa5e6e Mon Sep 17 00:00:00 2001 From: Kelvin Tan Date: Tue, 23 Sep 2025 18:15:38 +0800 Subject: [PATCH] DEV: refactor custom-user-selector to use DMultiSelect --- .../components/custom-user-selector.gjs | 192 ++++++++++++++++++ .../components/custom-user-selector.js | 144 ------------- .../custom-wizard-field-user-selector.hbs | 10 +- .../custom-wizard-field-user-selector.js | 52 +++++ .../common/wizard/autocomplete.scss | 13 ++ plugin.rb | 2 +- test/javascripts/acceptance/field-test.js | 6 +- 7 files changed, 269 insertions(+), 150 deletions(-) create mode 100644 assets/javascripts/discourse/components/custom-user-selector.gjs delete mode 100644 assets/javascripts/discourse/components/custom-user-selector.js diff --git a/assets/javascripts/discourse/components/custom-user-selector.gjs b/assets/javascripts/discourse/components/custom-user-selector.gjs new file mode 100644 index 0000000000..c445092def --- /dev/null +++ b/assets/javascripts/discourse/components/custom-user-selector.gjs @@ -0,0 +1,192 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { array } from "@ember/helper"; +import { action } from "@ember/object"; +import DMultiSelect from "discourse/components/d-multi-select"; +import avatar from "discourse/helpers/avatar"; +import icon from "discourse/helpers/d-icon"; +import userSearch from "discourse/lib/user-search"; + +/** + * Custom user selector component using DMultiSelect + * + * @component CustomUserSelector + * @param {string} @usernames - Comma-separated string of selected usernames (read-only) + * @param {boolean} @single - Whether to allow only single selection + * @param {boolean} @allowAny - Whether to allow any input + * @param {boolean} @disabled - Whether the component is disabled + * @param {boolean} @includeGroups - Whether to include groups in search + * @param {boolean} @includeMentionableGroups - Whether to include mentionable groups + * @param {boolean} @includeMessageableGroups - Whether to include messageable groups + * @param {boolean} @allowedUsers - Whether to restrict to allowed users only + * @param {string} @topicId - Topic ID for context-aware search + * @param {function} @onChangeCallback - Callback for selection changes + */ +export default class CustomUserSelector extends Component { + @tracked selectedUsers = []; + @tracked hasGroups = false; + @tracked usernames = ""; + + constructor(owner, args) { + super(owner, args); + this.parseInitialUsernames(); + } + + get includeMentionableGroups() { + return this.args.includeMentionableGroups === "true"; + } + + get includeMessageableGroups() { + return this.args.includeMessageableGroups === "true"; + } + + get includeGroups() { + return this.args.includeGroups === "true"; + } + + get allowedUsers() { + return this.args.allowedUsers === "true"; + } + + get single() { + return this.args.single; + } + + parseInitialUsernames() { + if (!this.args.usernames) { + this.selectedUsers = []; + return; + } + + const usernames = this.args.usernames.split(",").filter(Boolean); + this.selectedUsers = usernames.map((username) => { + const trimmedUsername = username.trim(); + // Create user object similar to what reverseTransform did in original + return { + username: trimmedUsername, + name: trimmedUsername, + id: trimmedUsername, + isUser: true + }; + }); + } + + @action + async loadUsers(searchTerm) { + const termRegex = /[^a-zA-Z0-9_\-\.@\+]/; + const cleanTerm = searchTerm ? searchTerm.replace(termRegex, "") : ""; + + // Get currently selected usernames for exclusion + const excludedUsernames = this.single + ? [] + : this.selectedUsers.map((u) => u.username); + + try { + const results = await userSearch({ + term: cleanTerm, + topicId: this.args.topicId, + exclude: excludedUsernames, + includeGroups: this.includeGroups, + allowedUsers: this.allowedUsers, + includeMentionableGroups: this.includeMentionableGroups, + includeMessageableGroups: this.includeMessageableGroups + }); + + // Transform results to include both users and groups + const transformedResults = []; + + if (results.users) { + transformedResults.push( + ...results.users.map((user) => ({ + ...user, + isUser: true, + id: user.username // Use username as ID for comparison + })) + ); + } + + if (results.groups) { + transformedResults.push( + ...results.groups.map((group) => ({ + ...group, + isGroup: true, + name: group.name, // Groups use name as username + id: group.name // Use name as ID for comparison + })) + ); + } + + return transformedResults; + } catch { + return []; + } + } + + @action + onSelectionChange(newSelection) { + let selectedUsers = newSelection || []; + + if (this.single && selectedUsers.length > 1) { + selectedUsers = [selectedUsers[selectedUsers.length - 1]]; + } + + this.selectedUsers = selectedUsers; + this.hasGroups = this.selectedUsers.some((item) => item.isGroup); + + this.usernames = this.selectedUsers + .map((item) => item.username || item.name) + .join(","); + + if (this.args.onChangeCallback) { + this.args.onChangeCallback(this.usernames); + } + } + + @action + compareUsers(a, b) { + return (a.username || a.name) === (b.username || b.name); + } + + +} diff --git a/assets/javascripts/discourse/components/custom-user-selector.js b/assets/javascripts/discourse/components/custom-user-selector.js deleted file mode 100644 index 64812607f0..0000000000 --- a/assets/javascripts/discourse/components/custom-user-selector.js +++ /dev/null @@ -1,144 +0,0 @@ -import { isEmpty } from "@ember/utils"; -import $ from "jquery"; -import TextField from "discourse/components/text-field"; -import { renderAvatar } from "discourse/helpers/user-avatar"; -import { default as computed, observes } from "discourse/lib/decorators"; -import userSearch from "discourse/lib/user-search"; -import { escapeExpression } from "discourse/lib/utilities"; -import { i18n } from "discourse-i18n"; - -const template = function (params) { - const options = params.options; - let html = "
"; - - if (options.users) { - html += ""; - } - - html += "
"; - - return html; -}; - -export default TextField.extend({ - attributeBindings: ["autofocus", "maxLength"], - autocorrect: false, - autocapitalize: false, - name: "user-selector", - id: "custom-member-selector", - - @computed("placeholderKey") - placeholder(placeholderKey) { - return placeholderKey ? i18n(placeholderKey) : ""; - }, - - @observes("usernames") - _update() { - if (this.get("canReceiveUpdates") === "true") { - this.didInsertElement({ updateData: true }); - } - }, - - didInsertElement(opts) { - this._super(); - let self = this, - selected = [], - groups = [], - includeMentionableGroups = - this.get("includeMentionableGroups") === "true", - includeMessageableGroups = - this.get("includeMessageableGroups") === "true", - includeGroups = this.get("includeGroups") === "true", - allowedUsers = this.get("allowedUsers") === "true"; - - function excludedUsernames() { - // hack works around some issues with allowAny eventing - const usernames = self.get("single") ? [] : selected; - return usernames; - } - $(this.element) - .val(this.get("usernames")) - .autocomplete({ - template, - disabled: this.get("disabled"), - single: this.get("single"), - allowAny: this.get("allowAny"), - updateData: opts && opts.updateData ? opts.updateData : false, - - dataSource(term) { - const termRegex = /[^a-zA-Z0-9_\-\.@\+]/; - let results = userSearch({ - term: term.replace(termRegex, ""), - topicId: self.get("topicId"), - exclude: excludedUsernames(), - includeGroups, - allowedUsers, - includeMentionableGroups, - includeMessageableGroups, - }); - - return results; - }, - - transformComplete(v) { - if (v.username || v.name) { - if (!v.username) { - groups.push(v.name); - } - return v.username || v.name; - } else { - let excludes = excludedUsernames(); - return v.usernames.filter(function (item) { - return excludes.indexOf(item) === -1; - }); - } - }, - - onChangeItems(items) { - let hasGroups = false; - items = items.map(function (i) { - if (groups.indexOf(i) > -1) { - hasGroups = true; - } - return i.username ? i.username : i; - }); - self.set("usernames", items.join(",")); - self.set("hasGroups", hasGroups); - - selected = items; - if (self.get("onChangeCallback")) { - self.sendAction("onChangeCallback"); - } - }, - - reverseTransform(i) { - return { username: i }; - }, - }); - }, - - willDestroyElement() { - this._super(); - $(this.element).autocomplete("destroy"); - }, - - // THIS IS A HUGE HACK TO SUPPORT CLEARING THE INPUT - @observes("usernames") - _clearInput: function () { - if (arguments.length > 1) { - if (isEmpty(this.get("usernames"))) { - $(this.element).parent().find("a").click(); - } - } - }, -}); diff --git a/assets/javascripts/discourse/components/custom-wizard-field-user-selector.hbs b/assets/javascripts/discourse/components/custom-wizard-field-user-selector.hbs index 82423adcae..0c2d1be9b4 100644 --- a/assets/javascripts/discourse/components/custom-wizard-field-user-selector.hbs +++ b/assets/javascripts/discourse/components/custom-wizard-field-user-selector.hbs @@ -1,5 +1,11 @@ {{custom-user-selector usernames=this.field.value - placeholderKey=this.field.placeholder - tabindex=this.field.tabindex + includeGroups=this._includeGroups + includeMentionableGroups=this._includeMentionableGroups + includeMessageableGroups=this._includeMessageableGroups + allowedUsers=this._allowedUsers + single=this._single + topicId=this._topicId + disabled=this._disabled + onChangeCallback=(action "updateFieldValue") }} \ No newline at end of file diff --git a/assets/javascripts/discourse/components/custom-wizard-field-user-selector.js b/assets/javascripts/discourse/components/custom-wizard-field-user-selector.js index 64741c6b92..9d01360c0e 100644 --- a/assets/javascripts/discourse/components/custom-wizard-field-user-selector.js +++ b/assets/javascripts/discourse/components/custom-wizard-field-user-selector.js @@ -1,5 +1,57 @@ import Component from "@ember/component"; +import { computed } from "@ember/object"; export default Component.extend({ classNameBindings: ["fieldClass"], + + @computed("includeGroups") + get _includeGroups() { + return this.get("includeGroups"); + }, + + @computed("includeMentionableGroups") + get _includeMentionableGroups() { + return this.get("includeMentionableGroups"); + }, + + @computed("includeMessageableGroups") + get _includeMessageableGroups() { + return this.get("includeMessageableGroups"); + }, + + @computed("allowedUsers") + get _allowedUsers() { + return this.get("allowedUsers"); + }, + + @computed("single") + get _single() { + return this.get("single"); + }, + + @computed("topicId") + get _topicId() { + return this.get("topicId"); + }, + + @computed("disabled") + get _disabled() { + return this.get("disabled"); + }, + + get _onChangeCallback() { + return this.get("onChangeCallback"); + }, + + actions: { + updateFieldValue(usernames) { + this.set("field.value", usernames); + + // Call the original callback if it exists + const originalCallback = this.get("onChangeCallback"); + if (originalCallback) { + originalCallback(); + } + }, + }, }); diff --git a/assets/stylesheets/common/wizard/autocomplete.scss b/assets/stylesheets/common/wizard/autocomplete.scss index 60ca01800f..c07ad64a14 100644 --- a/assets/stylesheets/common/wizard/autocomplete.scss +++ b/assets/stylesheets/common/wizard/autocomplete.scss @@ -169,4 +169,17 @@ body.custom-wizard { margin-right: 5px; } } + + // Custom user selector using DMultiSelect + .custom-user-selector { + .d-multi-select-trigger__selected-item > { + .d-multi-select-trigger__selection-label { + max-width: 10em; + } + } + + .d-multi-select-trigger { + width: 100%; + } + } } diff --git a/plugin.rb b/plugin.rb index 07f6efeed1..d78155b4c8 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # name: discourse-custom-wizard # about: Forms for Discourse. Better onboarding, structured posting, data enrichment, automated actions and much more. -# version: 2.13.1 +# version: 2.13.2 # authors: Angus McLeod, Faizaan Gagan, Robert Barrow, Keegan George, Kaitlin Maddever, Marcos Gutierrez # url: https://github.com/paviliondev/discourse-custom-wizard # contact_emails: development@pavilion.tech diff --git a/test/javascripts/acceptance/field-test.js b/test/javascripts/acceptance/field-test.js index 5084bdc078..b5c2e80f19 100644 --- a/test/javascripts/acceptance/field-test.js +++ b/test/javascripts/acceptance/field-test.js @@ -227,16 +227,16 @@ acceptance("Field | Fields", function (needs) { test("User", async function (assert) { await visit("/w/wizard"); await fillIn( - ".wizard-field.user-selector-field input.ember-text-field", + ".wizard-field.user-selector-field .d-multi-select-trigger input", "a" ); await triggerKeyEvent( - ".wizard-field.user-selector-field input.ember-text-field", + ".wizard-field.user-selector-field .d-multi-select-trigger input", "keyup", "a".charCodeAt(0) ); - assert.ok(visible(".wizard-field.user-selector-field .ac-wrap")); + assert.ok(visible(".wizard-field.user-selector-field .d-multi-select")); // TODO: add assertion for ac results. autocomplete does not appear in time. }); });