diff --git a/app/assets/javascripts/discourse/controllers/preferences/users.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/users.js.es6 index fc675cf052d..ba3e866901b 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/users.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/users.js.es6 @@ -1,4 +1,6 @@ +import { makeArray } from "discourse-common/lib/helpers"; import { alias, gte, or } from "@ember/object/computed"; +import { action, computed } from "@ember/object"; import Controller from "@ember/controller"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -8,19 +10,36 @@ export default Controller.extend(PreferencesTabController, { userIsMemberOrAbove: gte("model.trust_level", 2), ignoredEnabled: or("userIsMemberOrAbove", "model.staff"), + mutedUsernames: computed("model.muted_usernames", { + get() { + let usernames = this.model.muted_usernames; + + if (typeof usernames === "string") { + usernames = usernames.split(",").filter(Boolean); + } + + return makeArray(usernames).uniq(); + } + }), + init() { this._super(...arguments); this.saveAttrNames = ["muted_usernames", "ignored_usernames"]; }, - actions: { - save() { - this.set("saved", false); - return this.model - .save(this.saveAttrNames) - .then(() => this.set("saved", true)) - .catch(popupAjaxError); - } + @action + onChangeMutedUsernames(usernames) { + this.model.set("muted_usernames", usernames.uniq().join(",")); + }, + + @action + save() { + this.set("saved", false); + + return this.model + .save(this.saveAttrNames) + .then(() => this.set("saved", true)) + .catch(popupAjaxError); } }); diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index 2a5890aa3f4..fcdc4214278 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -27,7 +27,7 @@ function performSearch( return; } - const eagerComplete = term === "" && !!(topicId || categoryId); + const eagerComplete = eagerCompleteSearch(term, topicId || categoryId); if (term === "" && !eagerComplete) { // The server returns no results in this case, so no point checking @@ -138,7 +138,7 @@ function organizeResults(r, options) { // we also ignore if we notice a double space or a string that is only a space const ignoreRegex = /([\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*,\/:;<=>?\[\]^`{|}~])|\s\s|^\s$|^[^+]*\+[^@]*$/; -function skipSearch(term, allowEmails) { +export function skipSearch(term, allowEmails) { if (term.indexOf("@") > -1 && !allowEmails) { return true; } @@ -146,6 +146,10 @@ function skipSearch(term, allowEmails) { return !!term.match(ignoreRegex); } +export function eagerCompleteSearch(term, scopedId) { + return term === "" && !!scopedId; +} + export default function userSearch(options) { if (options.term && options.term.length > 0 && options.term[0] === "@") { options.term = options.term.substring(1); diff --git a/app/assets/javascripts/discourse/templates/preferences/users.hbs b/app/assets/javascripts/discourse/templates/preferences/users.hbs index c43db04fddb..50804371497 100644 --- a/app/assets/javascripts/discourse/templates/preferences/users.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/users.hbs @@ -14,7 +14,13 @@ {{d-icon "d-muted" class="icon"}} {{i18n 'user.muted_users'}} - {{user-selector excludeCurrentUser=true usernames=model.muted_usernames class="user-selector"}} + {{user-chooser + value=mutedUsernames + onChange=(action "onChangeMutedUsernames") + options=(hash + excludeCurrentUser=true + ) + }}
{{i18n 'user.muted_users_instructions'}}
diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index e7471b0e508..ba1197f02da 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -18,6 +18,7 @@ export default ComboBox.extend(TagsMixin, { maxTagSearchResults: setting("max_tag_search_results"), maxTagsPerTopic: setting("max_tags_per_topic"), highlightedTag: null, + singleSelect: false, collections: computed( "mainCollection.[]", diff --git a/app/assets/javascripts/select-kit/components/multi-select.js.es6 b/app/assets/javascripts/select-kit/components/multi-select.js.es6 index a15a9e79ea3..dd24a6b9175 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -1,7 +1,7 @@ import deprecated from "discourse-common/lib/deprecated"; import SelectKitComponent from "select-kit/components/select-kit"; import { computed } from "@ember/object"; -const { makeArray } = Ember; +import { makeArray } from "discourse-common/lib/helpers"; export default SelectKitComponent.extend({ pluginApiIdentifiers: ["multi-select"], diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6 index c1169723157..4f474009ec6 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -58,6 +58,8 @@ export default Component.extend( options: null, valueProperty: "id", nameProperty: "name", + singleSelect: false, + multiSelect: false, init() { this._super(...arguments); @@ -401,6 +403,28 @@ export default Component.extend( items = []; } + value = makeArray(value); + items = makeArray(items); + + if (this.multiSelect) { + items = items.filter( + i => + i !== this.newItem && + i !== this.noneItem && + this.getValue(i) !== null + ); + + if (this.selectKit.options.maximum === 1) { + value = value.slice(0, 1); + items = items.slice(0, 1); + } + } + + if (this.singleSelect) { + value = value.firstObject || null; + items = items.firstObject || null; + } + this._boundaryActionHandler("onChange", value, items); resolve(items); }).finally(() => { diff --git a/app/assets/javascripts/select-kit/components/user-chooser.js.es6 b/app/assets/javascripts/select-kit/components/user-chooser.js.es6 new file mode 100644 index 00000000000..b84e97ca9a4 --- /dev/null +++ b/app/assets/javascripts/select-kit/components/user-chooser.js.es6 @@ -0,0 +1,85 @@ +import MultiSelectComponent from "select-kit/components/multi-select"; +import { computed } from "@ember/object"; +import { + default as userSearch, + skipSearch, + eagerCompleteSearch +} from "discourse/lib/user-search"; +import { makeArray } from "discourse-common/lib/helpers"; + +export default MultiSelectComponent.extend({ + pluginApiIdentifiers: ["user-chooser"], + classNames: ["user-chooser"], + valueProperty: "username", + + modifyComponentForRow() { + return "user-chooser/user-row"; + }, + + selectKitOptions: { + topicId: undefined, + categoryId: undefined, + includeGroups: false, + allowedUsers: false, + includeMentionableGroups: false, + includeMessageableGroups: false, + allowEmails: false, + groupMembersOf: undefined + }, + + content: computed("value.[]", function() { + return Ember.makeArray(this.value).map(x => this.defaultItem(x, x)); + }), + + excludedUsers: computed( + "value", + "currentUser", + "selectKit.options.{excludeCurrentUser,excludedUsernames}", + { + get() { + const options = this.selectKit.options; + let usernames = makeArray(this.value); + + if (this.currentUser && options.excludeCurrentUser) { + usernames.concat([this.currentUser.username]); + } + + return usernames.concat(options.excludedUsernames || []); + } + } + ), + + search(filter = "") { + filter = filter || ""; + const options = this.selectKit.options; + + // prevents doing ajax request for nothing + const skippedSearch = skipSearch(filter, options.allowEmails); + const eagerComplete = eagerCompleteSearch( + filter, + options.topicId || options.categoryId + ); + if (skippedSearch || (filter === "" && !eagerComplete)) { + return; + } + + return userSearch({ + term: filter, + topicId: options.topicId, + categoryId: options.categoryId, + exclude: this.excludedUsers, + includeGroups: options.includeGroups, + allowedUsers: options.allowedUsers, + includeMentionableGroups: options.includeMentionableGroups, + includeMessageableGroups: options.includeMessageableGroups, + groupMembersOf: options.groupMembersOf, + allowEmails: options.allowEmails + }).then(result => { + if (typeof result === "string") { + // do nothing promise probably got cancelled + } else { + return result; + } + }); + } +}); diff --git a/app/assets/javascripts/select-kit/components/user-chooser/user-row.js.es6 b/app/assets/javascripts/select-kit/components/user-chooser/user-row.js.es6 new file mode 100644 index 00000000000..b85ac70d61b --- /dev/null +++ b/app/assets/javascripts/select-kit/components/user-chooser/user-row.js.es6 @@ -0,0 +1,6 @@ +import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; + +export default SelectKitRowComponent.extend({ + layoutName: "select-kit/templates/components/user-chooser/user-row", + classNames: ["user-row"] +}); diff --git a/app/assets/javascripts/select-kit/templates/components/user-chooser/user-row.hbs b/app/assets/javascripts/select-kit/templates/components/user-chooser/user-row.hbs new file mode 100644 index 00000000000..2fc47dda495 --- /dev/null +++ b/app/assets/javascripts/select-kit/templates/components/user-chooser/user-row.hbs @@ -0,0 +1,3 @@ +{{avatar item imageSize="tiny"}} +{{format-username item.username}} +{{item.name}} diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index d7ea24129f3..5aebe7c1e77 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -5,30 +5,7 @@ @import "common/foundation/mixins"; @import "common/foundation/variables"; @import "common/foundation/spacing"; -@import "common/select-kit/categories-admin-dropdown"; -@import "common/select-kit/category-chooser"; -@import "common/select-kit/category-drop"; -@import "common/select-kit/category-row"; -@import "common/select-kit/category-selector"; -@import "common/select-kit/combo-box"; -@import "common/select-kit/composer-actions"; -@import "common/select-kit/dropdown-select-box"; -@import "common/select-kit/future-date-input-selector"; -@import "common/select-kit/list-setting"; -@import "common/select-kit/mini-tag-chooser"; -@import "common/select-kit/multi-select"; -@import "common/select-kit/notifications-button"; -@import "common/select-kit/period-chooser"; -@import "common/select-kit/pinned-button"; -@import "common/select-kit/select-kit"; -@import "common/select-kit/single-select"; -@import "common/select-kit/tag-chooser"; -@import "common/select-kit/tag-drop"; -@import "common/select-kit/icon-picker"; -@import "common/select-kit/toolbar-popup-menu-options"; -@import "common/select-kit/topic-notifications-button"; -@import "common/select-kit/user-notifications-dropdown"; -@import "common/select-kit/color-palettes"; +@import "common/select-kit/*"; @import "common/components/*"; @import "common/input_tip"; @import "common/topic-entrance"; diff --git a/app/assets/stylesheets/common/select-kit/user-row.scss b/app/assets/stylesheets/common/select-kit/user-row.scss new file mode 100644 index 00000000000..fbc4f848b4c --- /dev/null +++ b/app/assets/stylesheets/common/select-kit/user-row.scss @@ -0,0 +1,22 @@ +.user-chooser { + .select-kit-row.user-row { + .avatar { + margin-left: 0; + margin-right: 0.5em; + } + + .username { + color: $primary; + white-space: nowrap; + } + + .name { + color: $primary-high; + font-size: $font-down-1; + margin-left: 0.5em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index a7c73db6c51..6cfaa87a813 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -239,7 +239,8 @@ .category-selector, .tag-chooser, textarea, - input.user-selector { + input.user-selector, + .user-chooser { width: 100%; } diff --git a/test/javascripts/components/select-kit/user-chooser-test.js.es6 b/test/javascripts/components/select-kit/user-chooser-test.js.es6 new file mode 100644 index 00000000000..068d8c6a1f0 --- /dev/null +++ b/test/javascripts/components/select-kit/user-chooser-test.js.es6 @@ -0,0 +1,58 @@ +import componentTest from "helpers/component-test"; +import { testSelectKitModule } from "./select-kit-test-helper"; + +testSelectKitModule("user-chooser"); + +function template() { + return `{{user-chooser value=value}}`; +} + +componentTest("displays usernames", { + template: template(), + + beforeEach() { + this.set("value", ["bob", "martin"]); + }, + + async test(assert) { + assert.equal(this.subject.header().name(), "bob,martin"); + } +}); + +componentTest("can remove a username", { + template: template(), + + beforeEach() { + this.set("value", ["bob", "martin"]); + }, + + async test(assert) { + await this.subject.deselectItem("bob"); + assert.equal(this.subject.header().name(), "martin"); + } +}); + +componentTest("can add a username", { + template: template(), + + beforeEach() { + this.set("value", ["bob", "martin"]); + + const response = object => { + return [200, { "Content-Type": "application/json" }, object]; + }; + + // prettier-ignore + server.get("/u/search/users", () => { //eslint-disable-line + return response({users:[{username: "maja", name: "Maja"}]}); + }); + }, + + async test(assert) { + await this.subject.expand(); + await this.subject.fillInFilter("maja"); + await this.subject.keyboard("enter"); + + assert.equal(this.subject.header().name(), "bob,martin,maja"); + } +}); diff --git a/test/javascripts/helpers/select-kit-helper.js.es6 b/test/javascripts/helpers/select-kit-helper.js.es6 index e78369de155..a6ada50b9b4 100644 --- a/test/javascripts/helpers/select-kit-helper.js.es6 +++ b/test/javascripts/helpers/select-kit-helper.js.es6 @@ -263,6 +263,14 @@ export default function selectKit(selector) { return rowHelper(find(selector).find(".select-kit-row.is-highlighted")); }, + async deselectItem(value) { + await click( + find(selector) + .find(".select-kit-header") + .find(`[data-value=${value}]`) + ); + }, + exists() { return exists(selector); }