diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index beb60b375aa..99745222cc9 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -65,7 +65,7 @@ {{/if}} {{#if canEditTags}} - {{tag-chooser tags=model.tags tabIndex="4" categoryId=model.categoryId}} + {{mini-tag-chooser tags=model.tags tabindex="4" categoryId=model.categoryId}} {{/if}} 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 new file mode 100644 index 00000000000..deaae9d01b8 --- /dev/null +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -0,0 +1,224 @@ +import ComboBox from "select-kit/components/combo-box"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { default as computed } from "ember-addons/ember-computed-decorators"; +import renderTag from "discourse/lib/render-tag"; +const { get, isEmpty, isPresent, run } = Ember; + +export default ComboBox.extend({ + allowContentReplacement: true, + pluginApiIdentifiers: ["mini-tag-chooser"], + classNames: ["mini-tag-chooser"], + classNameBindings: ["noTags"], + verticalOffset: 3, + filterable: true, + noTags: Ember.computed.empty("computedTags"), + allowAny: true, + + init() { + this._super(); + + this.set("termMatchesForbidden", false); + + this.set("templateForRow", (rowComponent) => { + const tag = rowComponent.get("computedContent"); + return renderTag(get(tag, "value"), { + count: get(tag, "originalContent.count"), + noHref: true + }); + }); + }, + + @computed("tags") + computedTags(tags) { + return Ember.makeArray(tags); + }, + + validateCreate(term) { + const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); + term = term.replace(filterRegexp, "").trim().toLowerCase(); + + if (!term.length || this.get("termMatchesForbidden")) { + return false; + } + + if (this.get("siteSettings.max_tag_length") < term.length) { + return false; + } + + return true; + }, + + validateSelect() { + return this.get("computedTags").length < this.get("siteSettings.max_tags_per_topic") && + this.site.get("can_create_tag"); + }, + + didRender() { + this._super(); + + this.$().on("click.mini-tag-chooser", ".selected-tag", (event) => { + event.stopImmediatePropagation(); + this.send("removeTag", $(event.target).attr("data-value")); + }); + }, + + willDestroyElement() { + this._super(); + + $(".select-kit-body").off("click.mini-tag-chooser"); + + const searchDebounce = this.get("searchDebounce"); + if (isPresent(searchDebounce)) { run.cancel(searchDebounce); } + }, + + didPressEscape(event) { + const $lastSelectedTag = $(".selected-tag.selected:last"); + + if ($lastSelectedTag && this.get("isExpanded")) { + $lastSelectedTag.removeClass("selected"); + this._destroyEvent(event); + } else { + this._super(event); + } + }, + + didPressBackspace() { + if (!this.get("isExpanded")) { + this.expand(); + return; + } + + const $lastSelectedTag = $(".selected-tag:last"); + + if (!isEmpty(this.get("filter"))) { + $lastSelectedTag.removeClass("is-highlighted"); + return; + } + + if (!$lastSelectedTag.length) return; + + if (!$lastSelectedTag.hasClass("is-highlighted")) { + $lastSelectedTag.addClass("is-highlighted"); + } else { + this.send("removeTag", $lastSelectedTag.attr("data-value")); + } + }, + + @computed("tags.[]", "filter") + collectionHeader(tags, filter) { + if (!Ember.isEmpty(tags)) { + let output = ""; + + if (tags.length >= 20) { + tags = tags.filter(t => t.indexOf(filter) >= 0); + } + + tags.map((tag) => { + output += ` + + `; + }); + + return `
`; + } + }, + + computeHeaderContent() { + let content = this.baseHeaderComputedContent(); + + if (isEmpty(this.get("computedTags"))) { + content.label = I18n.t("tagging.choose_for_topic"); + } else { + content.label = this.get("computedTags").join(","); + } + + return content; + }, + + actions: { + removeTag(tag) { + let tags = this.get("computedTags"); + delete tags[tags.indexOf(tag)]; + this.set("tags", tags.filter(t => t)); + this.set("content", []); + this.set("searchDebounce", run.debounce(this, this._searchTags, 200)); + }, + + onExpand() { + this.set("searchDebounce", run.debounce(this, this._searchTags, 200)); + }, + + onFilter(filter) { + filter = isEmpty(filter) ? null : filter; + this.set("searchDebounce", run.debounce(this, this._searchTags, filter, 200)); + }, + + onSelect(tag) { + if (isEmpty(this.get("computedTags"))) { + this.set("tags", Ember.makeArray(tag)); + } else { + this.set("tags", this.get("computedTags").concat(tag)); + } + + this.set("content", []); + this.set("searchDebounce", run.debounce(this, this._searchTags, 200)); + } + }, + + muateAttributes() { + this.set("value", null); + }, + + _searchTags(query) { + this.startLoading(); + + const selectedTags = Ember.makeArray(this.get("computedTags")).filter(t => t); + + const self = this; + + const sortTags = this.siteSettings.tags_sort_alphabetically; + + const data = { + q: query, + limit: this.siteSettings.max_tag_search_results, + categoryId: this.get("categoryId") + }; + + if (selectedTags) { + data.selected_tags = selectedTags.slice(0, 100); + } + + ajax(Discourse.getURL("/tags/filter/search"), { + quietMillis: 200, + cache: true, + dataType: "json", + data, + }).then(json => { + let results = json.results; + + self.set("termMatchesForbidden", json.forbidden ? true : false); + + if (sortTags) { + results = results.sort((a, b) => a.id > b.id); + } + + const content = results.map((result) => { + return { + id: result.text, + name: result.text, + count: result.count + }; + }).filter(c => !selectedTags.includes(c.id)); + + self.set("content", content); + self.stopLoading(); + this.autoHighlight(); + }).catch(error => { + self.stopLoading(); + popupAjaxError(error); + }); + } +}); 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 beec8844592..e3e0ad9a132 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -252,10 +252,6 @@ export default SelectKitComponent.extend({ this.autoHighlight(); }, - validateComputedContentItem(computedContentItem) { - return !this.get("computedValues").includes(computedContentItem.value); - }, - actions: { clearSelection() { this.send("deselect", this.get("selectedComputedContents")); @@ -263,7 +259,8 @@ export default SelectKitComponent.extend({ }, create(computedContentItem) { - if (this.validateComputedContentItem(computedContentItem)) { + if (!this.get("computedValues").includes(computedContentItem.value) && + this.validateCreate(computedContentItem.value)) { this.get("computedContent").pushObject(computedContentItem); this._boundaryActionHandler("onCreate"); this.send("select", computedContentItem); @@ -274,9 +271,14 @@ export default SelectKitComponent.extend({ select(computedContentItem) { this.willSelect(computedContentItem); - this.get("computedValues").pushObject(computedContentItem.value); - Ember.run.next(() => this.mutateAttributes()); - Ember.run.schedule("afterRender", () => this.didSelect(computedContentItem)); + + if (this.validateSelect(computedContentItem)) { + this.get("computedValues").pushObject(computedContentItem.value); + Ember.run.next(() => this.mutateAttributes()); + Ember.run.schedule("afterRender", () => this.didSelect(computedContentItem)); + } else { + this._boundaryActionHandler("onSelectFailure"); + } }, deselect(rowComputedContentItems) { 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 eb7b325524b..ab9852b1541 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -16,6 +16,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi layoutName: "select-kit/templates/components/select-kit", classNames: ["select-kit"], classNameBindings: [ + "isLoading", "isFocused", "isExpanded", "isDisabled", @@ -30,6 +31,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi isExpanded: false, isFocused: false, isHidden: false, + isLoading: false, renderedBodyOnce: false, renderedFilterOnce: false, tabindex: 0, @@ -41,6 +43,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi autoFilterable: false, filterable: false, filter: "", + previousFilter: null, filterPlaceholder: "select_kit.filter_placeholder", filterIcon: "search", headerIcon: null, @@ -118,6 +121,10 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi return this.baseComputedContentItem(contentItem, options); }, + validateCreate() { return true; }, + + validateSelect() { return true; }, + baseComputedContentItem(contentItem, options) { let originalContent; options = options || {}; @@ -163,7 +170,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi @computed("filter", "computedContent") shouldDisplayCreateRow(filter, computedContent) { if (computedContent.map(c => c.value).includes(filter)) return false; - if (this.get("allowAny") && filter.length > 0) return true; + if (this.get("allowAny") && filter.length > 0 && this.validateCreate(filter)) return true; return false; }, @@ -184,7 +191,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi @computed("filter") templateForCreateRow() { return (rowComponent) => { - return I18n.t("select_box.create", { + return I18n.t("select_kit.create", { content: rowComponent.get("computedContent.name") }); }; @@ -238,6 +245,16 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi this.setProperties({ filter: "" }); }, + startLoading() { + this.set("isLoading", true); + this._boundaryActionHandler("onStartLoading"); + }, + + stopLoading() { + this.set("isLoading", false); + this._boundaryActionHandler("onStopLoading"); + }, + _setCollectionHeaderComputedContent() { const collectionHeaderComputedContent = applyCollectionHeaderCallbacks( this.get("pluginApiIdentifiers"), @@ -283,9 +300,12 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi }, filterComputedContent(filter) { + if (filter === this.get("previousFilter")) return; + this.setProperties({ highlightedValue: null, renderedFilterOnce: true, + previousFilter: filter, filter }); this.autoHighlight(); diff --git a/app/assets/javascripts/select-kit/components/single-select.js.es6 b/app/assets/javascripts/select-kit/components/single-select.js.es6 index af7c1e035a1..d442a9e2ae7 100644 --- a/app/assets/javascripts/select-kit/components/single-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/single-select.js.es6 @@ -145,10 +145,6 @@ export default SelectKitComponent.extend({ }); }, - validateComputedContentItem(computedContentItem) { - return this.get("computedValue") !== computedContentItem.value; - }, - actions: { clearSelection() { this.send("deselect", this.get("selectedComputedContent")); @@ -156,7 +152,8 @@ export default SelectKitComponent.extend({ }, create(computedContentItem) { - if (this.validateComputedContentItem(computedContentItem)) { + if (this.get("computedValue") !== computedContentItem.value && + this.validateCreate(computedContentItem.value)) { this.get("computedContent").pushObject(computedContentItem); this._boundaryActionHandler("onCreate"); this.send("select", computedContentItem); @@ -166,10 +163,14 @@ export default SelectKitComponent.extend({ }, select(rowComputedContentItem) { - this.willSelect(rowComputedContentItem); - this.set("computedValue", rowComputedContentItem.value); - this.mutateAttributes(); - run.schedule("afterRender", () => this.didSelect(rowComputedContentItem)); + if (this.validateSelect(rowComputedContentItem)) { + this.willSelect(rowComputedContentItem); + this.set("computedValue", rowComputedContentItem.value); + this.mutateAttributes(); + run.schedule("afterRender", () => this.didSelect(rowComputedContentItem)); + } else { + this._boundaryActionHandler("onSelectFailure"); + } }, deselect(rowComputedContentItem) { diff --git a/app/assets/javascripts/select-kit/mixins/events.js.es6 b/app/assets/javascripts/select-kit/mixins/events.js.es6 index 87d4df25610..45edf38577f 100644 --- a/app/assets/javascripts/select-kit/mixins/events.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/events.js.es6 @@ -108,6 +108,7 @@ export default Ember.Mixin.create({ .on("keydown.select-kit", (event) => { const keyCode = event.keyCode || event.which; + if (keyCode === this.keys.BACKSPACE) this.backspaceFromFilter(event); if (keyCode === this.keys.TAB) this.tabFromFilter(event); if (keyCode === this.keys.ESC) this.escapeFromFilter(event); if (keyCode === this.keys.ENTER) this.enterFromFilter(event); @@ -207,6 +208,7 @@ export default Ember.Mixin.create({ upAndDownFromFilter(event) { this.didPressUpAndDownArrows(event); }, backspaceFromHeader(event) { this.didPressBackspace(event); }, + backspaceFromFilter(event) { this.didPressBackspace(event); }, enterFromHeader(event) { this.didPressEnter(event); }, enterFromFilter(event) { this.didPressEnter(event); }, diff --git a/app/assets/javascripts/select-kit/templates/components/select-kit.hbs b/app/assets/javascripts/select-kit/templates/components/select-kit.hbs index 20bc4ca19fa..5e6e936db4a 100644 --- a/app/assets/javascripts/select-kit/templates/components/select-kit.hbs +++ b/app/assets/javascripts/select-kit/templates/components/select-kit.hbs @@ -6,6 +6,7 @@ computedContent=headerComputedContent deselect=(action "deselect") toggle=(action "toggle") + isLoading=isLoading filterComputedContent=(action "filterComputedContent") clearSelection=(action "clearSelection") options=headerComponentOptions @@ -14,6 +15,7 @@