DEV: {{user-selector}} replacement (#11726)

This PR is the first step towards replacing our `{{user-selector}}` and eventually deprecating and removing it from our codebase. Some of `{{user-selector}}` problems are:

1. It's called `{{user-selector}}`, but in reality in can also select groups and emails.
2. It's an Ember component, yet it doesn't have a handlebars template and uses jQuery to render itself and modify the DOM. An example of this problem is when you want to clear the selected users programmatically, see [this](6c155dba77/app/assets/javascripts/discourse/app/components/user-selector.js (L179-L185)).
3. We now have select kit which does very similar things but a lot better.

This PR introduces `{{email-group-user-chooser}}` which is meant to replace `{{user-selector}}`. It extends select kit and has the same features that `{{user-selector}}` has. `{{user-selector}}` is still used in a few places in core, but they'll all be replaced with the new component in a separate commit. 

Once `{{user-selector}}` is not used anywhere in core, it'll be deprecated and then removed after the 2.7 release.
This commit is contained in:
Osama Sayegh
2021-02-01 13:07:11 +03:00
committed by GitHub
parent 7e4dad3c56
commit 98201ecc24
29 changed files with 481 additions and 227 deletions

View File

@ -3,6 +3,7 @@ import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { isBlank } from "@ember/utils"; import { isBlank } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { get } from "@ember/object";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
export default Controller.extend({ export default Controller.extend({
@ -30,6 +31,10 @@ export default Controller.extend({
}, },
actions: { actions: {
updateUsername(selected) {
this.set("model.username", get(selected, "firstObject"));
},
changeUserMode(value) { changeUserMode(value) {
if (value === "all") { if (value === "all") {
this.model.set("username", null); this.model.set("username", null);

View File

@ -2,6 +2,7 @@ import { empty, notEmpty, or } from "@ember/object/computed";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import EmailPreview from "admin/models/email-preview"; import EmailPreview from "admin/models/email-preview";
import bootbox from "bootbox"; import bootbox from "bootbox";
import { get } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
export default Controller.extend({ export default Controller.extend({
@ -14,6 +15,10 @@ export default Controller.extend({
htmlEmpty: empty("model.html_content"), htmlEmpty: empty("model.html_content"),
actions: { actions: {
updateUsername(selected) {
this.set("username", get(selected, "firstObject"));
},
refresh() { refresh() {
const model = this.model; const model = this.model;

View File

@ -1,6 +1,6 @@
import Controller, { inject as controller } from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object"; import { action, get } from "@ember/object";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
@ -27,4 +27,9 @@ export default Controller.extend(ModalFunctionality, {
close() { close() {
this.send("closeModal"); this.send("closeModal");
}, },
@action
updateUsername(selected) {
this.set("targetUsername", get(selected, "firstObject"));
},
}); });

View File

@ -25,10 +25,14 @@
{{#if showUserSelector}} {{#if showUserSelector}}
{{#admin-form-row label="admin.api.user"}} {{#admin-form-row label="admin.api.user"}}
{{user-selector single="true" {{email-group-user-chooser
usernames=model.username value=model.username
placeholderKey="admin.api.user_placeholder" onChange=(action "updateUsername")
}} options=(hash
maximum=1
filterPlaceholder="admin.api.user_placeholder"
)
}}
{{/admin-form-row}} {{/admin-form-row}}
{{/if}} {{/if}}

View File

@ -5,7 +5,13 @@
<label for="last-seen">{{i18n "admin.email.last_seen_user"}}</label> <label for="last-seen">{{i18n "admin.email.last_seen_user"}}</label>
{{date-picker-past value=lastSeen id="last-seen"}} {{date-picker-past value=lastSeen id="last-seen"}}
<label>{{i18n "admin.email.user"}}:</label> <label>{{i18n "admin.email.user"}}:</label>
{{user-selector single="true" usernames=username canReceiveUpdates=true}} {{email-group-user-chooser
value=username
onChange=(action "updateUsername")
options=(hash
maximum=1
)
}}
{{d-button {{d-button
class="btn-primary digest-refresh-button" class="btn-primary digest-refresh-button"
action=(action "refresh") action=(action "refresh")

View File

@ -1,10 +1,15 @@
<div> <div>
{{#d-modal-body rawTitle=(i18n "admin.user.merge.prompt.title" username=username)}} {{#d-modal-body rawTitle=(i18n "admin.user.merge.prompt.title" username=username)}}
<p>{{html-safe (i18n "admin.user.merge.prompt.description" username=username)}}</p> <p>{{html-safe (i18n "admin.user.merge.prompt.description" username=username)}}</p>
{{user-selector single=true {{email-group-user-chooser
placeholderKey="admin.user.merge.prompt.target_username_placeholder" value=targetUsername
usernames=targetUsername autocomplete="discourse"
autocomplete="discourse"}} onChange=(action "updateUsername")
options=(hash
maximum=1
filterPlaceholder="admin.user.merge.prompt.target_username_placeholder"
)
}}
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">

View File

@ -1,13 +1,12 @@
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import Component from "@ember/component"; import Component from "@ember/component";
import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end"; import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import { schedule } from "@ember/runloop";
export default Component.extend({ export default Component.extend({
showSelector: true, init() {
shouldHide: false, this._super(...arguments);
defaultUsernameCount: 0, this.set("_groups", []);
},
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
@ -17,78 +16,34 @@ export default Component.extend({
} }
}, },
@observes("usernames") @discourseComputed("recipients")
_checkWidth() { splitRecipients(recipients) {
let width = 0; return recipients ? recipients.split(",").filter(Boolean) : [];
const $acWrap = $(this.element).find(".ac-wrap");
const limit = $acWrap.width();
this.set("defaultUsernameCount", 0);
$acWrap
.find(".item")
.toArray()
.forEach((item) => {
width += $(item).outerWidth(true);
const result = width < limit;
if (result) {
this.incrementProperty("defaultUsernameCount");
}
return result;
});
if (width >= limit) {
this.set("shouldHide", true);
} else {
this.set("shouldHide", false);
}
}, },
@observes("shouldHide") _updateGroups(selected, newGroups) {
_setFocus() { const groups = [];
const selector = this._groups.forEach((existing) => {
"#reply-control #reply-title, #reply-control .d-editor-input"; if (selected.includes(existing)) {
groups.addObject(existing);
if (this.shouldHide) { }
$(selector).on("focus.composer-user-selector", () => { });
this.set("showSelector", false); newGroups.forEach((newGroup) => {
this.appEvents.trigger("composer:resize"); if (!groups.includes(newGroup)) {
}); groups.addObject(newGroup);
} else { }
$(selector).off("focus.composer-user-selector"); });
} this.setProperties({
}, _groups: groups,
hasGroups: groups.length > 0,
@discourseComputed("usernames") });
splitUsernames(usernames) {
return usernames.split(",");
},
@discourseComputed("splitUsernames", "defaultUsernameCount")
limitedUsernames(splitUsernames, count) {
return splitUsernames.slice(0, count).join(", ");
},
@discourseComputed("splitUsernames", "defaultUsernameCount")
hiddenUsersCount(splitUsernames, count) {
return `${splitUsernames.length - count} ${I18n.t("more")}`;
}, },
actions: { actions: {
toggleSelector() { updateRecipients(selected, content) {
this.set("showSelector", true); const newGroups = content.filterBy("isGroup").mapBy("id");
this._updateGroups(selected, newGroups);
schedule("afterRender", () => { this.set("recipients", selected.join(","));
$(this.element).find("input").focus();
});
},
triggerResize() {
this.appEvents.trigger("composer:resize");
const $this = $(this.element).find(".ac-wrap");
if ($this.height() >= 150) {
$this.scrollTop($this.height());
}
}, },
}, },
}); });

View File

@ -20,9 +20,9 @@ export default Component.extend({
isStaff: readOnly("currentUser.staff"), isStaff: readOnly("currentUser.staff"),
isAdmin: readOnly("currentUser.admin"), isAdmin: readOnly("currentUser.admin"),
// If this isn't defined, it will proxy to the user topic on the preferences // invitee is either a user, group or email
// page which is wrong. invitee: null,
emailOrUsername: null, isInviteeGroup: false,
hasCustomMessage: false, hasCustomMessage: false,
customMessage: null, customMessage: null,
inviteIcon: "envelope", inviteIcon: "envelope",
@ -41,7 +41,7 @@ export default Component.extend({
@discourseComputed( @discourseComputed(
"isAdmin", "isAdmin",
"emailOrUsername", "invitee",
"invitingToTopic", "invitingToTopic",
"isPrivateTopic", "isPrivateTopic",
"groupIds", "groupIds",
@ -50,7 +50,7 @@ export default Component.extend({
) )
disabled( disabled(
isAdmin, isAdmin,
emailOrUsername, invitee,
invitingToTopic, invitingToTopic,
isPrivateTopic, isPrivateTopic,
groupIds, groupIds,
@ -60,24 +60,22 @@ export default Component.extend({
if (saving) { if (saving) {
return true; return true;
} }
if (isEmpty(emailOrUsername)) { if (isEmpty(invitee)) {
return true; return true;
} }
const emailTrimmed = emailOrUsername.trim();
// when inviting to forum, email must be valid // when inviting to forum, email must be valid
if (!invitingToTopic && !emailValid(emailTrimmed)) { if (!invitingToTopic && !emailValid(invitee)) {
return true; return true;
} }
// normal users (not admin) can't invite users to private topic via email // normal users (not admin) can't invite users to private topic via email
if (!isAdmin && isPrivateTopic && emailValid(emailTrimmed)) { if (!isAdmin && isPrivateTopic && emailValid(invitee)) {
return true; return true;
} }
// when inviting to private topic via email, group name must be specified // when inviting to private topic via email, group name must be specified
if (isPrivateTopic && isEmpty(groupIds) && emailValid(emailTrimmed)) { if (isPrivateTopic && isEmpty(groupIds) && emailValid(invitee)) {
return true; return true;
} }
@ -90,7 +88,7 @@ export default Component.extend({
@discourseComputed( @discourseComputed(
"isAdmin", "isAdmin",
"emailOrUsername", "invitee",
"inviteModel.saving", "inviteModel.saving",
"isPrivateTopic", "isPrivateTopic",
"groupIds", "groupIds",
@ -98,7 +96,7 @@ export default Component.extend({
) )
disabledCopyLink( disabledCopyLink(
isAdmin, isAdmin,
emailOrUsername, invitee,
saving, saving,
isPrivateTopic, isPrivateTopic,
groupIds, groupIds,
@ -110,24 +108,22 @@ export default Component.extend({
if (saving) { if (saving) {
return true; return true;
} }
if (isEmpty(emailOrUsername)) { if (isEmpty(invitee)) {
return true; return true;
} }
const email = emailOrUsername.trim();
// email must be valid // email must be valid
if (!emailValid(email)) { if (!emailValid(invitee)) {
return true; return true;
} }
// normal users (not admin) can't invite users to private topic via email // normal users (not admin) can't invite users to private topic via email
if (!isAdmin && isPrivateTopic && emailValid(email)) { if (!isAdmin && isPrivateTopic && emailValid(invitee)) {
return true; return true;
} }
// when inviting to private topic via email, group name must be specified // when inviting to private topic via email, group name must be specified
if (isPrivateTopic && isEmpty(groupIds) && emailValid(email)) { if (isPrivateTopic && isEmpty(groupIds) && emailValid(invitee)) {
return true; return true;
} }
@ -179,7 +175,7 @@ export default Component.extend({
// Show Groups? (add invited user to private group) // Show Groups? (add invited user to private group)
@discourseComputed( @discourseComputed(
"isGroupOwnerOrAdmin", "isGroupOwnerOrAdmin",
"emailOrUsername", "invitee",
"isPrivateTopic", "isPrivateTopic",
"isPM", "isPM",
"invitingToTopic", "invitingToTopic",
@ -187,7 +183,7 @@ export default Component.extend({
) )
showGroups( showGroups(
isGroupOwnerOrAdmin, isGroupOwnerOrAdmin,
emailOrUsername, invitee,
isPrivateTopic, isPrivateTopic,
isPM, isPM,
invitingToTopic, invitingToTopic,
@ -197,20 +193,20 @@ export default Component.extend({
isGroupOwnerOrAdmin && isGroupOwnerOrAdmin &&
canInviteViaEmail && canInviteViaEmail &&
!isPM && !isPM &&
(emailValid(emailOrUsername) || isPrivateTopic || !invitingToTopic) (emailValid(invitee) || isPrivateTopic || !invitingToTopic)
); );
}, },
@discourseComputed("emailOrUsername") @discourseComputed("invitee")
showCustomMessage(emailOrUsername) { showCustomMessage(invitee) {
return this.inviteModel === this.currentUser || emailValid(emailOrUsername); return this.inviteModel === this.currentUser || emailValid(invitee);
}, },
// Instructional text for the modal. // Instructional text for the modal.
@discourseComputed( @discourseComputed(
"isPM", "isPM",
"invitingToTopic", "invitingToTopic",
"emailOrUsername", "invitee",
"isPrivateTopic", "isPrivateTopic",
"isAdmin", "isAdmin",
"canInviteViaEmail" "canInviteViaEmail"
@ -218,7 +214,7 @@ export default Component.extend({
inviteInstructions( inviteInstructions(
isPM, isPM,
invitingToTopic, invitingToTopic,
emailOrUsername, invitee,
isPrivateTopic, isPrivateTopic,
isAdmin, isAdmin,
canInviteViaEmail canInviteViaEmail
@ -236,9 +232,9 @@ export default Component.extend({
return I18n.t("topic.invite_reply.to_username"); return I18n.t("topic.invite_reply.to_username");
} else { } else {
// when inviting to a topic, display instructions based on provided entity // when inviting to a topic, display instructions based on provided entity
if (isEmpty(emailOrUsername)) { if (isEmpty(invitee)) {
return I18n.t("topic.invite_reply.to_topic_blank"); return I18n.t("topic.invite_reply.to_topic_blank");
} else if (emailValid(emailOrUsername)) { } else if (emailValid(invitee)) {
this.set("inviteIcon", "envelope"); this.set("inviteIcon", "envelope");
return I18n.t("topic.invite_reply.to_topic_email"); return I18n.t("topic.invite_reply.to_topic_email");
} else { } else {
@ -257,18 +253,18 @@ export default Component.extend({
return isPrivateTopic ? "required" : "optional"; return isPrivateTopic ? "required" : "optional";
}, },
@discourseComputed("isPM", "emailOrUsername", "invitingExistingUserToTopic") @discourseComputed("isPM", "invitee", "invitingExistingUserToTopic")
successMessage(isPM, emailOrUsername, invitingExistingUserToTopic) { successMessage(isPM, invitee, invitingExistingUserToTopic) {
if (this.hasGroups) { if (this.isInviteeGroup) {
return I18n.t("topic.invite_private.success_group"); return I18n.t("topic.invite_private.success_group");
} else if (isPM) { } else if (isPM) {
return I18n.t("topic.invite_private.success"); return I18n.t("topic.invite_private.success");
} else if (invitingExistingUserToTopic) { } else if (invitingExistingUserToTopic) {
return I18n.t("topic.invite_reply.success_existing_email", { return I18n.t("topic.invite_reply.success_existing_email", {
emailOrUsername, invitee,
}); });
} else if (emailValid(emailOrUsername)) { } else if (emailValid(invitee)) {
return I18n.t("topic.invite_reply.success_email", { emailOrUsername }); return I18n.t("topic.invite_reply.success_email", { invitee });
} else { } else {
return I18n.t("topic.invite_reply.success_username"); return I18n.t("topic.invite_reply.success_username");
} }
@ -295,7 +291,8 @@ export default Component.extend({
// Reset the modal to allow a new user to be invited. // Reset the modal to allow a new user to be invited.
reset() { reset() {
this.setProperties({ this.setProperties({
emailOrUsername: null, invitee: null,
isInviteeGroup: false,
hasCustomMessage: false, hasCustomMessage: false,
customMessage: null, customMessage: null,
invitingExistingUserToTopic: false, invitingExistingUserToTopic: false,
@ -346,9 +343,9 @@ export default Component.extend({
model.setProperties({ saving: false, error: true }); model.setProperties({ saving: false, error: true });
}; };
if (this.hasGroups) { if (this.isInviteeGroup) {
return this.inviteModel return this.inviteModel
.createGroupInvite(this.emailOrUsername.trim()) .createGroupInvite(this.invitee.trim())
.then((data) => { .then((data) => {
model.setProperties({ saving: false, finished: true }); model.setProperties({ saving: false, finished: true });
this.get("inviteModel.details.allowed_groups").pushObject( this.get("inviteModel.details.allowed_groups").pushObject(
@ -359,7 +356,7 @@ export default Component.extend({
.catch(onerror); .catch(onerror);
} else { } else {
return this.inviteModel return this.inviteModel
.createInvite(this.emailOrUsername.trim(), groupIds, this.customMessage) .createInvite(this.invitee.trim(), groupIds, this.customMessage)
.then((result) => { .then((result) => {
model.setProperties({ saving: false, finished: true }); model.setProperties({ saving: false, finished: true });
if (!this.invitingToTopic && userInvitedController) { if (!this.invitingToTopic && userInvitedController) {
@ -379,7 +376,7 @@ export default Component.extend({
this.appEvents.trigger("post-stream:refresh", { force: true }); this.appEvents.trigger("post-stream:refresh", { force: true });
} else if ( } else if (
this.invitingToTopic && this.invitingToTopic &&
emailValid(this.emailOrUsername.trim()) && emailValid(this.invitee.trim()) &&
result && result &&
result.user result.user
) { ) {
@ -407,7 +404,7 @@ export default Component.extend({
} }
return model return model
.generateInviteLink(this.emailOrUsername.trim(), groupIds, topicId) .generateInviteLink(this.invitee.trim(), groupIds, topicId)
.then((result) => { .then((result) => {
model.setProperties({ model.setProperties({
saving: false, saving: false,
@ -465,7 +462,23 @@ export default Component.extend({
@action @action
searchContact() { searchContact() {
getNativeContact(this.capabilities, ["email"], false).then((result) => { getNativeContact(this.capabilities, ["email"], false).then((result) => {
this.set("emailOrUsername", result[0].email[0]); this.set("invitee", result[0].email[0]);
}); });
}, },
@action
updateInvitee(selected, content) {
const invitee = content.findBy("id", selected[0]);
if (invitee) {
this.setProperties({
invitee: invitee.id.trim(),
isInviteeGroup: invitee.isGroup || false,
});
} else {
this.setProperties({
invitee: null,
isInviteeGroup: false,
});
}
},
}); });

View File

@ -1,22 +1,14 @@
{{#if showSelector}} {{email-group-user-chooser
{{user-selector id="private-message-users"
tabindex="1"
autocomplete="discourse"
value=splitRecipients
onChange=(action "updateRecipients")
options=(hash
topicId=topicId topicId=topicId
onChangeCallback=(action "triggerResize") filterPlaceholder="composer.users_placeholder"
id="private-message-users" includeMessageableGroups=true
includeMessageableGroups="true" allowEmails=true
placeholderKey="composer.users_placeholder" autoWrap=true
tabindex="1" )
usernames=usernames }}
hasGroups=hasGroups
allowEmails="true"
autocomplete="discourse"
canReceiveUpdates=true
}}
{{else}}
<a href {{action "toggleSelector"}}>
<div class="ac-wrap composer-user-selector-limited">
<span>{{limitedUsernames}}</span>
<span class="btn btn-primary">{{hiddenUsersCount}}</span>
</div>
</a>
{{/if}}

View File

@ -7,7 +7,7 @@
<div class="body"> <div class="body">
{{#if inviteModel.finished}} {{#if inviteModel.finished}}
{{#if inviteModel.inviteLink}} {{#if inviteModel.inviteLink}}
{{generated-invite-link link=inviteModel.inviteLink email=emailOrUsername}} {{generated-invite-link link=inviteModel.inviteLink email=invitee}}
{{else}} {{else}}
<div class="success-message"> <div class="success-message">
{{html-safe successMessage}} {{html-safe successMessage}}
@ -18,24 +18,23 @@
<label class="instructions">{{inviteInstructions}}</label> <label class="instructions">{{inviteInstructions}}</label>
<div class="invite-user-input-wrapper"> <div class="invite-user-input-wrapper">
{{#if allowExistingMembers}} {{#if allowExistingMembers}}
{{user-selector {{email-group-user-chooser
fullWidthWrap=true
single=true
allowAny=true
excludeCurrentUser=true
includeMessageableGroups=isPM
hasGroups=hasGroups
usernames=emailOrUsername
placeholderKey=placeholderKey
allowEmails=canInviteViaEmail
class="invite-user-input" class="invite-user-input"
autocomplete="discourse" autocomplete="discourse"
value=emailOrUsername value=invitee
onChange=(action "updateInvitee")
options=(hash
maximum=1
allowEmails=canInviteViaEmail
excludeCurrentUser=true
includeMessageableGroups=isPM
filterPlaceholder=placeholderKey
)
}} }}
{{else}} {{else}}
{{text-field {{text-field
class="email-or-username-input" class="email-or-username-input"
value=emailOrUsername value=invitee
placeholderKey="topic.invite_reply.email_placeholder"}} placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}} {{/if}}
{{#if capabilities.hasContactPicker}} {{#if capabilities.hasContactPicker}}

View File

@ -52,11 +52,13 @@
{{#if model.canEditTitle}} {{#if model.canEditTitle}}
{{#if model.creatingPrivateMessage}} {{#if model.creatingPrivateMessage}}
<div class="user-selector"> <div class="user-selector">
{{composer-user-selector topicId=topicModel.id {{composer-user-selector
usernames=model.targetRecipients topicId=topicModel.id
hasGroups=model.hasTargetGroups recipients=model.targetRecipients
focusTarget=focusTarget hasGroups=model.hasTargetGroups
class="users-input"}} focusTarget=focusTarget
class="users-input"
}}
{{#if showWarning}} {{#if showWarning}}
<label class="add-warning"> <label class="add-warning">
{{input type="checkbox" checked=model.isWarning tabindex="3"}} {{input type="checkbox" checked=model.isWarning tabindex="3"}}

View File

@ -62,7 +62,9 @@ acceptance("Composer Actions", function (needs) {
await composerActions.selectRowByValue("reply_as_private_message"); await composerActions.selectRowByValue("reply_as_private_message");
assert.equal( assert.equal(
queryAll(".users-input .item:nth-of-type(1)").text(), queryAll("#private-message-users .selected-name:nth-of-type(1)")
.text()
.trim(),
"codinghorror" "codinghorror"
); );
assert.ok( assert.ok(
@ -164,7 +166,7 @@ acceptance("Composer Actions", function (needs) {
await composerActions.selectRowByValue("reply_as_new_group_message"); await composerActions.selectRowByValue("reply_as_new_group_message");
const items = []; const items = [];
queryAll(".users-input .item").each((_, item) => queryAll("#private-message-users .selected-name").each((_, item) =>
items.push(item.textContent.trim()) items.push(item.textContent.trim())
); );
@ -348,7 +350,9 @@ acceptance("Composer Actions", function (needs) {
await composerActions.selectRowByValue("reply_as_private_message"); await composerActions.selectRowByValue("reply_as_private_message");
assert.equal( assert.equal(
queryAll(".users-input .item:nth-of-type(1)").text(), queryAll("#private-message-users .selected-name:nth-of-type(1)")
.text()
.trim(),
"uwe_keim" "uwe_keim"
); );
assert.ok( assert.ok(

View File

@ -713,7 +713,9 @@ acceptance("Composer", function (needs) {
await click(".modal .btn-default"); await click(".modal .btn-default");
assert.equal( assert.equal(
queryAll(".users-input .item:nth-of-type(1)").text(), queryAll("#private-message-users .selected-name:nth-of-type(1)")
.text()
.trim(),
"codinghorror" "codinghorror"
); );
} finally { } finally {

View File

@ -215,7 +215,7 @@ acceptance("Group - Authenticated", function (needs) {
assert.ok(count("#reply-control") === 1, "it opens the composer"); assert.ok(count("#reply-control") === 1, "it opens the composer");
assert.equal( assert.equal(
queryAll(".ac-wrap .item").text(), queryAll("#private-message-users .selected-name").text().trim(),
"discourse", "discourse",
"it prefills the group name" "it prefills the group name"
); );

View File

@ -36,7 +36,9 @@ acceptance("New Message - Authenticated", function (needs) {
"it pre-fills message body" "it pre-fills message body"
); );
assert.equal( assert.equal(
queryAll(".users-input .item:nth-of-type(1)").text().trim(), queryAll("#private-message-users .selected-name:nth-of-type(1)")
.text()
.trim(),
"charlie", "charlie",
"it selects correct username" "it selects correct username"
); );

View File

@ -67,22 +67,25 @@ acceptance("Topic", function (needs) {
"it fills composer with the ring string" "it fills composer with the ring string"
); );
const targets = queryAll(".item span", ".composer-fields"); const targets = queryAll(
"#private-message-users .selected-name",
".composer-fields"
);
assert.equal( assert.equal(
$(targets[0]).text(), $(targets[0]).text().trim(),
"someguy", "someguy",
"it fills up the composer with the right user to start the PM to" "it fills up the composer with the right user to start the PM to"
); );
assert.equal( assert.equal(
$(targets[1]).text(), $(targets[1]).text().trim(),
"test", "test",
"it fills up the composer with the right user to start the PM to" "it fills up the composer with the right user to start the PM to"
); );
assert.equal( assert.equal(
$(targets[2]).text(), $(targets[2]).text().trim(),
"Group", "Group",
"it fills up the composer with the right group to start the PM to" "it fills up the composer with the right group to start the PM to"
); );

View File

@ -0,0 +1,76 @@
import MultiSelectHeaderComponent from "select-kit/components/multi-select/multi-select-header";
import { computed } from "@ember/object";
import { gt } from "@ember/object/computed";
import { isTesting } from "discourse-common/config/environment";
import layout from "select-kit/templates/components/email-group-user-chooser-header";
export default MultiSelectHeaderComponent.extend({
layout,
classNames: ["email-group-user-chooser-header"],
hasHiddenItems: gt("hiddenItemsCount", 0),
shownItems: computed("hiddenItemsCount", function () {
if (
this.selectKit.noneItem === this.selectedContent ||
this.hiddenItemsCount === 0
) {
return this.selectedContent;
}
return this.selectedContent.slice(
0,
this.selectedContent.length - this.hiddenItemsCount
);
}),
hiddenItemsCount: computed(
"selectedContent.[]",
"selectKit.options.autoWrap",
"selectKit.isExpanded",
function () {
if (
!this.selectKit.options.autoWrap ||
this.selectKit.isExpanded ||
this.selectedContent === this.selectKit.noneItem ||
this.selectedContent.length <= 1 ||
isTesting()
) {
return 0;
} else {
const selectKitHeaderWidth = this.element.offsetWidth;
const choices = this.element.querySelectorAll(".selected-name.choice");
const input = this.element.querySelector(".filter-input");
const alreadyHidden = this.element.querySelector(".x-more-item");
if (alreadyHidden) {
const hiddenCount = parseInt(
alreadyHidden.getAttribute("data-hidden-count"),
10
);
return (
hiddenCount +
(this.selectedContent.length - (choices.length + hiddenCount))
);
}
if (choices.length === 0 && this.selectedContent.length > 0) {
return 0;
}
let total = choices[0].offsetWidth + input.offsetWidth;
let shownItemsCount = 1;
let shouldHide = false;
for (let i = 1; i < choices.length - 1; i++) {
const currentWidth = choices[i].offsetWidth;
const nextWidth = choices[i + 1].offsetWidth;
const ratio =
(total + currentWidth + nextWidth) / selectKitHeaderWidth;
if (ratio >= 0.95) {
shouldHide = true;
break;
} else {
shownItemsCount++;
total += currentWidth;
}
}
return shouldHide ? choices.length - shownItemsCount : 0;
}
}
),
});

View File

@ -0,0 +1,7 @@
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
import layout from "select-kit/templates/components/email-group-user-chooser-row";
export default SelectKitRowComponent.extend({
layout,
classNames: ["email-group-user-chooser-row"],
});

View File

@ -0,0 +1,46 @@
import UserChooserComponent from "select-kit/components/user-chooser";
export default UserChooserComponent.extend({
pluginApiIdentifiers: ["email-group-user-chooser"],
classNames: ["email-group-user-chooser"],
valueProperty: "id",
nameProperty: "name",
modifyComponentForRow() {
return "email-group-user-chooser-row";
},
selectKitOptions: {
headerComponent: "email-group-user-chooser-header",
autoWrap: false,
},
search() {
const superPromise = this._super(...arguments);
if (!superPromise) {
return;
}
return superPromise.then((results) => {
if (!results || results.length === 0) {
return;
}
return results.map((item) => {
const reconstructed = {};
if (item.username) {
reconstructed.id = item.username;
if (item.username.includes("@")) {
reconstructed.isEmail = true;
} else {
reconstructed.isUser = true;
reconstructed.name = item.name;
}
} else if (item.name) {
reconstructed.id = item.name;
reconstructed.name = item.full_name;
reconstructed.isGroup = true;
}
return Object.assign({}, item, reconstructed);
});
});
},
});

View File

@ -87,6 +87,7 @@ export default Component.extend(
isHidden: false, isHidden: false,
isExpanded: false, isExpanded: false,
isFilterExpanded: false, isFilterExpanded: false,
enterDisabled: false,
hasSelection: false, hasSelection: false,
hasNoContent: true, hasNoContent: true,
highlighted: null, highlighted: null,
@ -570,64 +571,75 @@ export default Component.extend(
_searchWrapper(filter) { _searchWrapper(filter) {
this.clearErrors(); this.clearErrors();
this.setProperties({ mainCollection: [], "selectKit.isLoading": true }); this.setProperties({
mainCollection: [],
"selectKit.isLoading": true,
"selectKit.enterDisabled": true,
});
this._safeAfterRender(() => this.popper && this.popper.update()); this._safeAfterRender(() => this.popper && this.popper.update());
let content = []; let content = [];
return Promise.resolve(this.search(filter)).then((result) => { return Promise.resolve(this.search(filter))
content = content.concat(makeArray(result)); .then((result) => {
content = this.selectKit.modifyContent(content).filter(Boolean); content = content.concat(makeArray(result));
content = this.selectKit.modifyContent(content).filter(Boolean);
if (this.selectKit.valueProperty) { if (this.selectKit.valueProperty) {
content = content.uniqBy(this.selectKit.valueProperty); content = content.uniqBy(this.selectKit.valueProperty);
} else { } else {
content = content.uniq(); content = content.uniq();
}
if (this.selectKit.options.limitMatches) {
content = content.slice(0, this.selectKit.options.limitMatches);
}
const noneItem = this.selectKit.noneItem;
if (
this.selectKit.options.allowAny &&
filter &&
this.getName(noneItem) !== filter
) {
filter = this.createContentFromInput(filter);
if (this.validateCreate(filter, content)) {
this.selectKit.set("newItem", this.defaultItem(filter, filter));
content.unshift(this.selectKit.newItem);
} }
}
const hasNoContent = isEmpty(content); if (this.selectKit.options.limitMatches) {
content = content.slice(0, this.selectKit.options.limitMatches);
}
if ( const noneItem = this.selectKit.noneItem;
this.selectKit.hasSelection && if (
noneItem && this.selectKit.options.allowAny &&
this.selectKit.options.autoInsertNoneItem filter &&
) { this.getName(noneItem) !== filter
content.unshift(noneItem); ) {
} filter = this.createContentFromInput(filter);
if (this.validateCreate(filter, content)) {
this.selectKit.set("newItem", this.defaultItem(filter, filter));
content.unshift(this.selectKit.newItem);
}
}
this.set("mainCollection", content); const hasNoContent = isEmpty(content);
this.selectKit.setProperties({ if (
highlighted: this.selectKit.hasSelection &&
this.singleSelect && this.value noneItem &&
? this.itemForValue(this.value, this.mainCollection) this.selectKit.options.autoInsertNoneItem
: this.mainCollection.firstObject, ) {
isLoading: false, content.unshift(noneItem);
hasNoContent, }
this.set("mainCollection", content);
this.selectKit.setProperties({
highlighted:
this.singleSelect && this.value
? this.itemForValue(this.value, this.mainCollection)
: this.mainCollection.firstObject,
isLoading: false,
hasNoContent,
});
this._safeAfterRender(() => {
this.popper && this.popper.update();
this._focusFilter();
});
})
.finally(() => {
if (this.isDestroyed || this.isDestroying) {
return;
}
this.set("selectKit.enterDisabled", false);
}); });
this._safeAfterRender(() => {
this.popper && this.popper.update();
this._focusFilter();
});
});
}, },
_safeAfterRender(fn) { _safeAfterRender(fn) {
@ -854,11 +866,12 @@ export default Component.extend(
} }
const popperElement = data.state.elements.popper; const popperElement = data.state.elements.popper;
if ( const topPlacement =
popperElement && popperElement &&
popperElement.getAttribute("data-popper-placement") === popperElement
"top-start" .getAttribute("data-popper-placement")
) { .startsWith("top-");
if (topPlacement) {
this.element.classList.remove("is-under"); this.element.classList.remove("is-under");
this.element.classList.add("is-above"); this.element.classList.add("is-above");
} else { } else {
@ -868,6 +881,20 @@ export default Component.extend(
wrapper.style.width = `${this.element.offsetWidth}px`; wrapper.style.width = `${this.element.offsetWidth}px`;
wrapper.style.height = `${height}px`; wrapper.style.height = `${height}px`;
if (placementStrategy === "fixed") {
const rects = this.element.getClientRects()[0];
const bodyRects = body && body.getClientRects()[0];
wrapper.style.position = "fixed";
wrapper.style.left = `${rects.left}px`;
if (topPlacement && bodyRects) {
wrapper.style.top = `${rects.top - bodyRects.height}px`;
} else {
wrapper.style.top = `${rects.top}px`;
}
if (isDocumentRTL()) {
wrapper.style.right = "unset";
}
}
} }
}, },
}, },

View File

@ -56,6 +56,16 @@ export default Component.extend(UtilsMixin, {
return true; return true;
}, },
onKeyup(event) {
if (event.keyCode === 13 && this.selectKit.enterDisabled) {
this.element.querySelector("input").focus();
event.preventDefault();
event.stopPropagation();
return false;
}
return true;
},
onKeydown(event) { onKeydown(event) {
if (!this.selectKit.onKeydown(event)) { if (!this.selectKit.onKeydown(event)) {
return false; return false;
@ -93,8 +103,15 @@ export default Component.extend(UtilsMixin, {
return false; return false;
} }
if (event.keyCode === 13 && !this.selectKit.highlighted) { if (
event.keyCode === 13 &&
(!this.selectKit.highlighted || this.selectKit.enterDisabled)
) {
this.element.querySelector("input").focus(); this.element.querySelector("input").focus();
if (this.selectKit.enterDisabled) {
event.preventDefault();
event.stopPropagation();
}
return false; return false;
} }
@ -109,6 +126,7 @@ export default Component.extend(UtilsMixin, {
this.selectKit.close(event); this.selectKit.close(event);
return; return;
} }
this.selectKit.set("highlighted", null);
}, },
}, },
}); });

View File

@ -24,6 +24,7 @@ export default MultiSelectComponent.extend({
includeMessageableGroups: false, includeMessageableGroups: false,
allowEmails: false, allowEmails: false,
groupMembersOf: undefined, groupMembersOf: undefined,
excludeCurrentUser: false,
}, },
content: computed("value.[]", function () { content: computed("value.[]", function () {

View File

@ -0,0 +1,22 @@
<div class="choices">
{{#each shownItems as |item|}}
{{component selectKit.options.selectedNameComponent
tabindex=tabindex
item=item
selectKit=selectKit
}}
{{/each}}
{{#if hasHiddenItems}}
<div class="x-more-item" data-hidden-count={{hiddenItemsCount}}>
{{i18n "x_more" count=hiddenItemsCount}}
</div>
{{/if}}
{{#unless hasReachedMaximumSelection}}
<div class="choice input-wrapper">
{{component selectKit.options.filterComponent
selectKit=selectKit
}}
</div>
{{/unless}}
</div>

View File

@ -0,0 +1,12 @@
{{#if item.isUser}}
{{avatar item imageSize="tiny"}}
<span class="identifier">{{format-username item.id}}</span>
<span class="name">{{item.name}}</span>
{{else if item.isGroup}}
{{d-icon "users"}}
<span class="identifier">{{item.id}}</span>
<span class="name">{{item.full_name}}</span>
{{else}}
{{d-icon "envelope"}}
<span class="identifier">{{item.id}}</span>
{{/if}}

View File

@ -11,6 +11,7 @@
input=(action "onInput") input=(action "onInput")
paste=(action "onPaste") paste=(action "onPaste")
keyDown=(action "onKeydown") keyDown=(action "onKeydown")
keyUp=(action "onKeyup")
}} }}
{{#if selectKit.options.filterIcon}} {{#if selectKit.options.filterIcon}}

View File

@ -100,7 +100,8 @@ table.api-keys {
.value-list, .value-list,
.select-kit, .select-kit,
input[type="text"] { input[type="text"],
input[type="text"].filter-input {
width: 100%; width: 100%;
margin: 0; margin: 0;
} }

View File

@ -7,6 +7,7 @@
@import "combo-box"; @import "combo-box";
@import "composer-actions"; @import "composer-actions";
@import "dropdown-select-box"; @import "dropdown-select-box";
@import "email-group-user-chooser";
@import "future-date-input-selector"; @import "future-date-input-selector";
@import "icon-picker"; @import "icon-picker";
@import "list-setting"; @import "list-setting";

View File

@ -0,0 +1,37 @@
.select-kit.email-group-user-chooser {
.select-kit-row.email-group-user-chooser-row {
.identifier {
color: var(--primary);
white-space: nowrap;
}
.name {
color: var(--primary-high);
font-size: $font-down-1;
margin-left: 0.5em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.avatar,
.d-icon {
margin-left: 0;
margin-right: 0.5em;
}
}
.select-kit-header {
.x-more-item {
background: var(--primary-low);
padding: 0.25em;
flex: 1;
align-items: center;
display: flex;
justify-content: space-between;
box-sizing: border-box;
margin: 2px 0 0px 3px;
float: left;
height: 30px;
color: inherit;
outline: none;
}
}
}

View File

@ -245,6 +245,9 @@ en:
now: "just now" now: "just now"
read_more: "read more" read_more: "read more"
more: "More" more: "More"
x_more:
one: "%{count} More"
other: "%{count} More"
less: "Less" less: "Less"
never: "never" never: "never"
every_30_minutes: "every 30 minutes" every_30_minutes: "every 30 minutes"