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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 { isBlank } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { get } from "@ember/object";
import showModal from "discourse/lib/show-modal";
export default Controller.extend({
@ -30,6 +31,10 @@ export default Controller.extend({
},
actions: {
updateUsername(selected) {
this.set("model.username", get(selected, "firstObject"));
},
changeUserMode(value) {
if (value === "all") {
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 EmailPreview from "admin/models/email-preview";
import bootbox from "bootbox";
import { get } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Controller.extend({
@ -14,6 +15,10 @@ export default Controller.extend({
htmlEmpty: empty("model.html_content"),
actions: {
updateUsername(selected) {
this.set("username", get(selected, "firstObject"));
},
refresh() {
const model = this.model;

View File

@ -1,6 +1,6 @@
import Controller, { inject as controller } from "@ember/controller";
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 discourseComputed from "discourse-common/utils/decorators";
@ -27,4 +27,9 @@ export default Controller.extend(ModalFunctionality, {
close() {
this.send("closeModal");
},
@action
updateUsername(selected) {
this.set("targetUsername", get(selected, "firstObject"));
},
});

View File

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

View File

@ -5,7 +5,13 @@
<label for="last-seen">{{i18n "admin.email.last_seen_user"}}</label>
{{date-picker-past value=lastSeen id="last-seen"}}
<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
class="btn-primary digest-refresh-button"
action=(action "refresh")

View File

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

View File

@ -1,13 +1,12 @@
import discourseComputed, { observes } from "discourse-common/utils/decorators";
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 { schedule } from "@ember/runloop";
export default Component.extend({
showSelector: true,
shouldHide: false,
defaultUsernameCount: 0,
init() {
this._super(...arguments);
this.set("_groups", []);
},
didInsertElement() {
this._super(...arguments);
@ -17,78 +16,34 @@ export default Component.extend({
}
},
@observes("usernames")
_checkWidth() {
let width = 0;
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);
}
@discourseComputed("recipients")
splitRecipients(recipients) {
return recipients ? recipients.split(",").filter(Boolean) : [];
},
@observes("shouldHide")
_setFocus() {
const selector =
"#reply-control #reply-title, #reply-control .d-editor-input";
if (this.shouldHide) {
$(selector).on("focus.composer-user-selector", () => {
this.set("showSelector", false);
this.appEvents.trigger("composer:resize");
});
} else {
$(selector).off("focus.composer-user-selector");
}
},
@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")}`;
_updateGroups(selected, newGroups) {
const groups = [];
this._groups.forEach((existing) => {
if (selected.includes(existing)) {
groups.addObject(existing);
}
});
newGroups.forEach((newGroup) => {
if (!groups.includes(newGroup)) {
groups.addObject(newGroup);
}
});
this.setProperties({
_groups: groups,
hasGroups: groups.length > 0,
});
},
actions: {
toggleSelector() {
this.set("showSelector", true);
schedule("afterRender", () => {
$(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());
}
updateRecipients(selected, content) {
const newGroups = content.filterBy("isGroup").mapBy("id");
this._updateGroups(selected, newGroups);
this.set("recipients", selected.join(","));
},
},
});

View File

@ -20,9 +20,9 @@ export default Component.extend({
isStaff: readOnly("currentUser.staff"),
isAdmin: readOnly("currentUser.admin"),
// If this isn't defined, it will proxy to the user topic on the preferences
// page which is wrong.
emailOrUsername: null,
// invitee is either a user, group or email
invitee: null,
isInviteeGroup: false,
hasCustomMessage: false,
customMessage: null,
inviteIcon: "envelope",
@ -41,7 +41,7 @@ export default Component.extend({
@discourseComputed(
"isAdmin",
"emailOrUsername",
"invitee",
"invitingToTopic",
"isPrivateTopic",
"groupIds",
@ -50,7 +50,7 @@ export default Component.extend({
)
disabled(
isAdmin,
emailOrUsername,
invitee,
invitingToTopic,
isPrivateTopic,
groupIds,
@ -60,24 +60,22 @@ export default Component.extend({
if (saving) {
return true;
}
if (isEmpty(emailOrUsername)) {
if (isEmpty(invitee)) {
return true;
}
const emailTrimmed = emailOrUsername.trim();
// when inviting to forum, email must be valid
if (!invitingToTopic && !emailValid(emailTrimmed)) {
if (!invitingToTopic && !emailValid(invitee)) {
return true;
}
// 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;
}
// 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;
}
@ -90,7 +88,7 @@ export default Component.extend({
@discourseComputed(
"isAdmin",
"emailOrUsername",
"invitee",
"inviteModel.saving",
"isPrivateTopic",
"groupIds",
@ -98,7 +96,7 @@ export default Component.extend({
)
disabledCopyLink(
isAdmin,
emailOrUsername,
invitee,
saving,
isPrivateTopic,
groupIds,
@ -110,24 +108,22 @@ export default Component.extend({
if (saving) {
return true;
}
if (isEmpty(emailOrUsername)) {
if (isEmpty(invitee)) {
return true;
}
const email = emailOrUsername.trim();
// email must be valid
if (!emailValid(email)) {
if (!emailValid(invitee)) {
return true;
}
// 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;
}
// 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;
}
@ -179,7 +175,7 @@ export default Component.extend({
// Show Groups? (add invited user to private group)
@discourseComputed(
"isGroupOwnerOrAdmin",
"emailOrUsername",
"invitee",
"isPrivateTopic",
"isPM",
"invitingToTopic",
@ -187,7 +183,7 @@ export default Component.extend({
)
showGroups(
isGroupOwnerOrAdmin,
emailOrUsername,
invitee,
isPrivateTopic,
isPM,
invitingToTopic,
@ -197,20 +193,20 @@ export default Component.extend({
isGroupOwnerOrAdmin &&
canInviteViaEmail &&
!isPM &&
(emailValid(emailOrUsername) || isPrivateTopic || !invitingToTopic)
(emailValid(invitee) || isPrivateTopic || !invitingToTopic)
);
},
@discourseComputed("emailOrUsername")
showCustomMessage(emailOrUsername) {
return this.inviteModel === this.currentUser || emailValid(emailOrUsername);
@discourseComputed("invitee")
showCustomMessage(invitee) {
return this.inviteModel === this.currentUser || emailValid(invitee);
},
// Instructional text for the modal.
@discourseComputed(
"isPM",
"invitingToTopic",
"emailOrUsername",
"invitee",
"isPrivateTopic",
"isAdmin",
"canInviteViaEmail"
@ -218,7 +214,7 @@ export default Component.extend({
inviteInstructions(
isPM,
invitingToTopic,
emailOrUsername,
invitee,
isPrivateTopic,
isAdmin,
canInviteViaEmail
@ -236,9 +232,9 @@ export default Component.extend({
return I18n.t("topic.invite_reply.to_username");
} else {
// 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");
} else if (emailValid(emailOrUsername)) {
} else if (emailValid(invitee)) {
this.set("inviteIcon", "envelope");
return I18n.t("topic.invite_reply.to_topic_email");
} else {
@ -257,18 +253,18 @@ export default Component.extend({
return isPrivateTopic ? "required" : "optional";
},
@discourseComputed("isPM", "emailOrUsername", "invitingExistingUserToTopic")
successMessage(isPM, emailOrUsername, invitingExistingUserToTopic) {
if (this.hasGroups) {
@discourseComputed("isPM", "invitee", "invitingExistingUserToTopic")
successMessage(isPM, invitee, invitingExistingUserToTopic) {
if (this.isInviteeGroup) {
return I18n.t("topic.invite_private.success_group");
} else if (isPM) {
return I18n.t("topic.invite_private.success");
} else if (invitingExistingUserToTopic) {
return I18n.t("topic.invite_reply.success_existing_email", {
emailOrUsername,
invitee,
});
} else if (emailValid(emailOrUsername)) {
return I18n.t("topic.invite_reply.success_email", { emailOrUsername });
} else if (emailValid(invitee)) {
return I18n.t("topic.invite_reply.success_email", { invitee });
} else {
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() {
this.setProperties({
emailOrUsername: null,
invitee: null,
isInviteeGroup: false,
hasCustomMessage: false,
customMessage: null,
invitingExistingUserToTopic: false,
@ -346,9 +343,9 @@ export default Component.extend({
model.setProperties({ saving: false, error: true });
};
if (this.hasGroups) {
if (this.isInviteeGroup) {
return this.inviteModel
.createGroupInvite(this.emailOrUsername.trim())
.createGroupInvite(this.invitee.trim())
.then((data) => {
model.setProperties({ saving: false, finished: true });
this.get("inviteModel.details.allowed_groups").pushObject(
@ -359,7 +356,7 @@ export default Component.extend({
.catch(onerror);
} else {
return this.inviteModel
.createInvite(this.emailOrUsername.trim(), groupIds, this.customMessage)
.createInvite(this.invitee.trim(), groupIds, this.customMessage)
.then((result) => {
model.setProperties({ saving: false, finished: true });
if (!this.invitingToTopic && userInvitedController) {
@ -379,7 +376,7 @@ export default Component.extend({
this.appEvents.trigger("post-stream:refresh", { force: true });
} else if (
this.invitingToTopic &&
emailValid(this.emailOrUsername.trim()) &&
emailValid(this.invitee.trim()) &&
result &&
result.user
) {
@ -407,7 +404,7 @@ export default Component.extend({
}
return model
.generateInviteLink(this.emailOrUsername.trim(), groupIds, topicId)
.generateInviteLink(this.invitee.trim(), groupIds, topicId)
.then((result) => {
model.setProperties({
saving: false,
@ -465,7 +462,23 @@ export default Component.extend({
@action
searchContact() {
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}}
{{user-selector
{{email-group-user-chooser
id="private-message-users"
tabindex="1"
autocomplete="discourse"
value=splitRecipients
onChange=(action "updateRecipients")
options=(hash
topicId=topicId
onChangeCallback=(action "triggerResize")
id="private-message-users"
includeMessageableGroups="true"
placeholderKey="composer.users_placeholder"
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}}
filterPlaceholder="composer.users_placeholder"
includeMessageableGroups=true
allowEmails=true
autoWrap=true
)
}}

View File

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

View File

@ -52,11 +52,13 @@
{{#if model.canEditTitle}}
{{#if model.creatingPrivateMessage}}
<div class="user-selector">
{{composer-user-selector topicId=topicModel.id
usernames=model.targetRecipients
hasGroups=model.hasTargetGroups
focusTarget=focusTarget
class="users-input"}}
{{composer-user-selector
topicId=topicModel.id
recipients=model.targetRecipients
hasGroups=model.hasTargetGroups
focusTarget=focusTarget
class="users-input"
}}
{{#if showWarning}}
<label class="add-warning">
{{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");
assert.equal(
queryAll(".users-input .item:nth-of-type(1)").text(),
queryAll("#private-message-users .selected-name:nth-of-type(1)")
.text()
.trim(),
"codinghorror"
);
assert.ok(
@ -164,7 +166,7 @@ acceptance("Composer Actions", function (needs) {
await composerActions.selectRowByValue("reply_as_new_group_message");
const items = [];
queryAll(".users-input .item").each((_, item) =>
queryAll("#private-message-users .selected-name").each((_, item) =>
items.push(item.textContent.trim())
);
@ -348,7 +350,9 @@ acceptance("Composer Actions", function (needs) {
await composerActions.selectRowByValue("reply_as_private_message");
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"
);
assert.ok(

View File

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

View File

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

View File

@ -36,7 +36,9 @@ acceptance("New Message - Authenticated", function (needs) {
"it pre-fills message body"
);
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",
"it selects correct username"
);

View File

@ -67,22 +67,25 @@ acceptance("Topic", function (needs) {
"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(
$(targets[0]).text(),
$(targets[0]).text().trim(),
"someguy",
"it fills up the composer with the right user to start the PM to"
);
assert.equal(
$(targets[1]).text(),
$(targets[1]).text().trim(),
"test",
"it fills up the composer with the right user to start the PM to"
);
assert.equal(
$(targets[2]).text(),
$(targets[2]).text().trim(),
"Group",
"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,
isExpanded: false,
isFilterExpanded: false,
enterDisabled: false,
hasSelection: false,
hasNoContent: true,
highlighted: null,
@ -570,64 +571,75 @@ export default Component.extend(
_searchWrapper(filter) {
this.clearErrors();
this.setProperties({ mainCollection: [], "selectKit.isLoading": true });
this.setProperties({
mainCollection: [],
"selectKit.isLoading": true,
"selectKit.enterDisabled": true,
});
this._safeAfterRender(() => this.popper && this.popper.update());
let content = [];
return Promise.resolve(this.search(filter)).then((result) => {
content = content.concat(makeArray(result));
content = this.selectKit.modifyContent(content).filter(Boolean);
return Promise.resolve(this.search(filter))
.then((result) => {
content = content.concat(makeArray(result));
content = this.selectKit.modifyContent(content).filter(Boolean);
if (this.selectKit.valueProperty) {
content = content.uniqBy(this.selectKit.valueProperty);
} else {
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);
if (this.selectKit.valueProperty) {
content = content.uniqBy(this.selectKit.valueProperty);
} else {
content = content.uniq();
}
}
const hasNoContent = isEmpty(content);
if (this.selectKit.options.limitMatches) {
content = content.slice(0, this.selectKit.options.limitMatches);
}
if (
this.selectKit.hasSelection &&
noneItem &&
this.selectKit.options.autoInsertNoneItem
) {
content.unshift(noneItem);
}
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);
}
}
this.set("mainCollection", content);
const hasNoContent = isEmpty(content);
this.selectKit.setProperties({
highlighted:
this.singleSelect && this.value
? this.itemForValue(this.value, this.mainCollection)
: this.mainCollection.firstObject,
isLoading: false,
hasNoContent,
if (
this.selectKit.hasSelection &&
noneItem &&
this.selectKit.options.autoInsertNoneItem
) {
content.unshift(noneItem);
}
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) {
@ -854,11 +866,12 @@ export default Component.extend(
}
const popperElement = data.state.elements.popper;
if (
const topPlacement =
popperElement &&
popperElement.getAttribute("data-popper-placement") ===
"top-start"
) {
popperElement
.getAttribute("data-popper-placement")
.startsWith("top-");
if (topPlacement) {
this.element.classList.remove("is-under");
this.element.classList.add("is-above");
} else {
@ -868,6 +881,20 @@ export default Component.extend(
wrapper.style.width = `${this.element.offsetWidth}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;
},
onKeyup(event) {
if (event.keyCode === 13 && this.selectKit.enterDisabled) {
this.element.querySelector("input").focus();
event.preventDefault();
event.stopPropagation();
return false;
}
return true;
},
onKeydown(event) {
if (!this.selectKit.onKeydown(event)) {
return false;
@ -93,8 +103,15 @@ export default Component.extend(UtilsMixin, {
return false;
}
if (event.keyCode === 13 && !this.selectKit.highlighted) {
if (
event.keyCode === 13 &&
(!this.selectKit.highlighted || this.selectKit.enterDisabled)
) {
this.element.querySelector("input").focus();
if (this.selectKit.enterDisabled) {
event.preventDefault();
event.stopPropagation();
}
return false;
}
@ -109,6 +126,7 @@ export default Component.extend(UtilsMixin, {
this.selectKit.close(event);
return;
}
this.selectKit.set("highlighted", null);
},
},
});

View File

@ -24,6 +24,7 @@ export default MultiSelectComponent.extend({
includeMessageableGroups: false,
allowEmails: false,
groupMembersOf: undefined,
excludeCurrentUser: false,
},
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")
paste=(action "onPaste")
keyDown=(action "onKeydown")
keyUp=(action "onKeyup")
}}
{{#if selectKit.options.filterIcon}}

View File

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

View File

@ -7,6 +7,7 @@
@import "combo-box";
@import "composer-actions";
@import "dropdown-select-box";
@import "email-group-user-chooser";
@import "future-date-input-selector";
@import "icon-picker";
@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"
read_more: "read more"
more: "More"
x_more:
one: "%{count} More"
other: "%{count} More"
less: "Less"
never: "never"
every_30_minutes: "every 30 minutes"