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);
+ }
+
+
+
+ <:selection as |user|>
+ {{#if user.isGroup}}
+ {{user.name}}
+ {{else}}
+ {{user.username}}
+ {{/if}}
+
+
+ <:result as |user|>
+ {{#if user.isGroup}}
+
+ {{icon "users" class="group-icon"}}
+ {{user.name}}
+
+ {{else}}
+
+ {{avatar user imageSize="tiny"}}
+ {{user.username}}
+ {{#if user.name}}
+ {{user.name}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+}
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.
});
});