FEATURE: Tag synonyms

FEATURE: Tag synonyms

This feature adds the ability to define synonyms for tags, and the ability to merge one tag into another while keeping it as a synonym. For example, tags named “js” and “java-script” can be synonyms of “javascript”. When searching and creating topics using synonyms, they will be mapped to the base tag.

Along with this change is a new UI found on each tag’s page (for example, /tags/javascript) where more information about the tag can be shown. It will list the synonyms, which categories it’s restricted to (if any), and which tag groups it belongs to (if tag group names are public on the /tags page by enabling the “tags listed by group” setting). Staff users will be able to manage tags in this UI, merge tags, and add/remove synonyms.

diff --git a/app/assets/javascripts/admin/templates/web-hooks-show.hbs b/app/assets/javascripts/admin/templates/web-hooks-show.hbs
index 3cd6db8c0d..cc49f3ac58 100644
--- a/app/assets/javascripts/admin/templates/web-hooks-show.hbs
+++ b/app/assets/javascripts/admin/templates/web-hooks-show.hbs
@@ -54,7 +54,7 @@
       {{#if showTagsFilter}}
         <div class="filter">
           <label>{{d-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.tags_filter'}}</label>
-          {{tag-chooser tags=model.tag_names everyTag=true}}
+          {{tag-chooser tags=model.tag_names everyTag=true excludeSynonyms=true}}
           <div class="instructions">{{i18n 'admin.web_hooks.tags_filter_instructions'}}</div>
         </div>
       {{/if}}
diff --git a/app/assets/javascripts/discourse/adapters/tag-info.js.es6 b/app/assets/javascripts/discourse/adapters/tag-info.js.es6
new file mode 100644
index 0000000000..ca04b6d212
--- /dev/null
+++ b/app/assets/javascripts/discourse/adapters/tag-info.js.es6
@@ -0,0 +1,7 @@
+import RESTAdapter from "discourse/adapters/rest";
+
+export default RESTAdapter.extend({
+  pathFor(store, type, id) {
+    return "/tags/" + id + "/info";
+  }
+});
diff --git a/app/assets/javascripts/discourse/components/tag-info.js.es6 b/app/assets/javascripts/discourse/components/tag-info.js.es6
new file mode 100644
index 0000000000..bb143c5b0c
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/tag-info.js.es6
@@ -0,0 +1,133 @@
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import showModal from "discourse/lib/show-modal";
+import {
+  default as discourseComputed,
+  observes
+} from "discourse-common/utils/decorators";
+import Component from "@ember/component";
+import { reads, and } from "@ember/object/computed";
+import { isEmpty } from "@ember/utils";
+import Category from "discourse/models/category";
+
+export default Component.extend({
+  tagName: "",
+  loading: false,
+  tagInfo: null,
+  newSynonyms: null,
+  showEditControls: false,
+  canAdminTag: reads("currentUser.staff"),
+  editSynonymsMode: and("canAdminTag", "showEditControls"),
+
+  @discourseComputed("tagInfo.tag_group_names")
+  tagGroupsInfo(tagGroupNames) {
+    return I18n.t("tagging.tag_groups_info", {
+      count: tagGroupNames.length,
+      tag_groups: tagGroupNames.join(", ")
+    });
+  },
+
+  @discourseComputed("tagInfo.categories")
+  categoriesInfo(categories) {
+    return I18n.t("tagging.category_restrictions", {
+      count: categories.length
+    });
+  },
+
+  @discourseComputed(
+    "tagInfo.tag_group_names",
+    "tagInfo.categories",
+    "tagInfo.synonyms"
+  )
+  nothingToShow(tagGroupNames, categories, synonyms) {
+    return isEmpty(tagGroupNames) && isEmpty(categories) && isEmpty(synonyms);
+  },
+
+  @observes("expanded")
+  toggleExpanded() {
+    if (this.expanded && !this.tagInfo) {
+      this.loadTagInfo();
+    }
+  },
+
+  loadTagInfo() {
+    if (this.loading) {
+      return;
+    }
+    this.set("loading", true);
+    return this.store
+      .find("tag-info", this.tag.id)
+      .then(result => {
+        this.set("tagInfo", result);
+        this.set(
+          "tagInfo.synonyms",
+          result.synonyms.map(s => this.store.createRecord("tag", s))
+        );
+        this.set(
+          "tagInfo.categories",
+          result.category_ids.map(id => Category.findById(id))
+        );
+      })
+      .finally(() => this.set("loading", false));
+  },
+
+  actions: {
+    toggleEditControls() {
+      this.toggleProperty("showEditControls");
+    },
+
+    renameTag() {
+      showModal("rename-tag", { model: this.tag });
+    },
+
+    deleteTag() {
+      this.sendAction("deleteAction", this.tagInfo);
+    },
+
+    unlinkSynonym(tag) {
+      ajax(`/tags/${this.tagInfo.name}/synonyms/${tag.id}`, {
+        type: "DELETE"
+      })
+        .then(() => this.tagInfo.synonyms.removeObject(tag))
+        .catch(() => bootbox.alert(I18n.t("generic_error")));
+    },
+
+    deleteSynonym(tag) {
+      bootbox.confirm(
+        I18n.t("tagging.delete_synonym_confirm", { tag_name: tag.text }),
+        result => {
+          if (!result) return;
+
+          tag
+            .destroyRecord()
+            .then(() => this.tagInfo.synonyms.removeObject(tag))
+            .catch(() => bootbox.alert(I18n.t("generic_error")));
+        }
+      );
+    },
+
+    addSynonyms() {
+      ajax(`/tags/${this.tagInfo.name}/synonyms`, {
+        type: "POST",
+        data: {
+          synonyms: this.newSynonyms
+        }
+      })
+        .then(result => {
+          if (result.success) {
+            this.set("newSynonyms", null);
+            this.loadTagInfo();
+          } else if (result.failed_tags) {
+            bootbox.alert(
+              I18n.t("tagging.add_synonyms_failed", {
+                tag_names: Object.keys(result.failed_tags).join(", ")
+              })
+            );
+          } else {
+            bootbox.alert(I18n.t("generic_error"));
+          }
+        })
+        .catch(popupAjaxError);
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 b/app/assets/javascripts/discourse/controllers/tags-show.js.es6
index 34aa673c96..a34a368a1a 100644
--- a/app/assets/javascripts/discourse/controllers/tags-show.js.es6
+++ b/app/assets/javascripts/discourse/controllers/tags-show.js.es6
@@ -26,6 +26,7 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
   search: null,
   max_posts: null,
   q: null,
+  showInfo: false,
 
   categories: alias("site.categoriesList"),
 
@@ -79,9 +80,9 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
     return Discourse.SiteSettings.show_filter_by_tag;
   },
 
-  @discourseComputed("additionalTags", "canAdminTag", "category")
-  showAdminControls(additionalTags, canAdminTag, category) {
-    return !additionalTags && canAdminTag && !category;
+  @discourseComputed("additionalTags", "category", "tag.id")
+  showToggleInfo(additionalTags, category, tagId) {
+    return !additionalTags && !category && tagId !== "none";
   },
 
   loadMoreTopics() {
@@ -121,6 +122,10 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
       this.send("invalidateModel");
     },
 
+    toggleInfo() {
+      this.toggleProperty("showInfo");
+    },
+
     refresh() {
       // TODO: this probably doesn't work anymore
       return this.store
@@ -131,15 +136,23 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
         });
     },
 
-    deleteTag() {
+    deleteTag(tagInfo) {
       const numTopics =
         this.get("list.topic_list.tags.firstObject.topic_count") || 0;
 
-      const confirmText =
+      let confirmText =
         numTopics === 0
           ? I18n.t("tagging.delete_confirm_no_topics")
           : I18n.t("tagging.delete_confirm", { count: numTopics });
 
+      if (tagInfo.synonyms.length > 0) {
+        confirmText +=
+          " " +
+          I18n.t("tagging.delete_confirm_synonyms", {
+            count: tagInfo.synonyms.length
+          });
+      }
+
       bootbox.confirm(confirmText, result => {
         if (!result) return;
 
diff --git a/app/assets/javascripts/discourse/lib/render-tag.js.es6 b/app/assets/javascripts/discourse/lib/render-tag.js.es6
index 7a83678608..5206e2c900 100644
--- a/app/assets/javascripts/discourse/lib/render-tag.js.es6
+++ b/app/assets/javascripts/discourse/lib/render-tag.js.es6
@@ -28,6 +28,9 @@ function defaultRenderTag(tag, params) {
   if (Discourse.SiteSettings.tag_style || params.style) {
     classes.push(params.style || Discourse.SiteSettings.tag_style);
   }
+  if (params.size) {
+    classes.push(params.size);
+  }
 
   let val =
     "<" +
diff --git a/app/assets/javascripts/discourse/routes/tags-show.js.es6 b/app/assets/javascripts/discourse/routes/tags-show.js.es6
index ccbb0292cb..44b6bc6e1d 100644
--- a/app/assets/javascripts/discourse/routes/tags-show.js.es6

[... diff too long, it was truncated ...]

GitHub sha: 875f0d8f

We’re trying to get away from observers. Could we trigger this load instead when the user clicks to expand?

This should be automatic if you use the rest serializer format. The store will automatically use the proper classes for all the related objects.

It’s smart to use popupAjaxError as a generic handler because it’ll look for errors messages in our standard response and if not present will use a generic message.

Can we do a clear: both in the CSS for .tag-list instead?

How do I get the “synonyms” to be Tag js objects? Also the category_ids aren’t getting turned into Category objects by default.