From 4a7f560a351558df0cd4031f7f247818cbc5b831 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Mon, 18 Jan 2016 17:08:24 +0800
Subject: [PATCH] FEATURE: Decorate category hashtag links.

---
 .../discourse/components/d-editor.js.es6      |   3 +-
 .../discourse/helpers/category-link.js.es6    |   2 +-
 .../discourse/lib/category-hashtags.js.es6    |  26 ++++
 .../lib/link-category-hashtags.js.es6         |   6 +-
 .../javascripts/discourse/views/post.js.es6   |  10 +-
 .../common/components/badges.css.scss         | 115 +++++++++++++-----
 .../lib/category-hashtags-test.js.es6         |  22 ++++
 7 files changed, 144 insertions(+), 40 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/lib/category-hashtags.js.es6
 create mode 100644 test/javascripts/lib/category-hashtags-test.js.es6

diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6
index d4f0f7d82..83d9b5ce6 100644
--- a/app/assets/javascripts/discourse/components/d-editor.js.es6
+++ b/app/assets/javascripts/discourse/components/d-editor.js.es6
@@ -3,6 +3,7 @@ import loadScript from 'discourse/lib/load-script';
 import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
 import { showSelector } from "discourse/lib/emoji/emoji-toolbar";
 import Category from 'discourse/models/category';
+import { SEPARATOR as categoryHashtagSeparator } from 'discourse/lib/category-hashtags';
 
 // Our head can be a static string or a function that returns a string
 // based on input (like for numbered lists).
@@ -255,7 +256,7 @@ export default Ember.Component.extend({
       template: template,
       key: '#',
       transformComplete(category) {
-        return Category.slugFor(category, ":");
+        return Category.slugFor(category, categoryHashtagSeparator);
       },
       dataSource(term) {
         return Category.search(term);
diff --git a/app/assets/javascripts/discourse/helpers/category-link.js.es6 b/app/assets/javascripts/discourse/helpers/category-link.js.es6
index 9f7d3f4ed..bd517eabf 100644
--- a/app/assets/javascripts/discourse/helpers/category-link.js.es6
+++ b/app/assets/javascripts/discourse/helpers/category-link.js.es6
@@ -21,7 +21,7 @@ export function categoryBadgeHTML(category, opts) {
 
   var description = get(category, 'description_text'),
       restricted = get(category, 'read_restricted'),
-      url = Discourse.getURL("/c/") + Discourse.Category.slugFor(category),
+      url = opts.url ? opts.url : Discourse.getURL("/c/") + Discourse.Category.slugFor(category),
       href = (opts.link === false ? '' : url),
       tagName = (opts.link === false || opts.link === "false" ? 'span' : 'a'),
       extraClasses = (opts.extraClasses ? (' ' + opts.extraClasses) : ''),
diff --git a/app/assets/javascripts/discourse/lib/category-hashtags.js.es6 b/app/assets/javascripts/discourse/lib/category-hashtags.js.es6
new file mode 100644
index 000000000..2e06ac523
--- /dev/null
+++ b/app/assets/javascripts/discourse/lib/category-hashtags.js.es6
@@ -0,0 +1,26 @@
+import Category from 'discourse/models/category';
+import { categoryBadgeHTML } from 'discourse/helpers/category-link';
+
+export const SEPARATOR = ":";
+
+export function findCategoryByHashtagSlug(hashtagSlug) {
+  if (hashtagSlug.indexOf('#') === 0) hashtagSlug = hashtagSlug.slice(1);
+  return Category.findBySlug.apply(null, hashtagSlug.split(SEPARATOR, 2).reverse());
+};
+
+export function replaceSpan($elem, categorySlug, categoryLink) {
+  const category = findCategoryByHashtagSlug(categorySlug);
+
+  if (!category) {
+    $elem.replaceWith(categorySlug);
+  } else {
+    $elem.replaceWith(categoryBadgeHTML(
+      category, { url: categoryLink, allowUncategorized: true }
+    ));
+  }
+};
+
+export function decorateLinks($elems) {
+  $elems.each((_, elem) => replaceSpan($(elem), elem.text, elem.href));
+}
+
diff --git a/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6 b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
index 446e1aa53..53b3a8c2a 100644
--- a/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
+++ b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
@@ -1,12 +1,10 @@
+import { replaceSpan } from 'discourse/lib/category-hashtags';
+
 const validCategoryHashtags = {};
 const checkedCategoryHashtags = [];
 const testedKey = 'tested';
 const testedClass = `hashtag-${testedKey}`;
 
-function replaceSpan($elem, categorySlug, categoryLink) {
-  $elem.replaceWith(`<a href="${categoryLink}" class="hashtag">#${categorySlug}</a>`);
-}
-
 function updateFound($hashtags, categorySlugs) {
   Ember.run.schedule('afterRender', () => {
     $hashtags.each((index, hashtag) => {
diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6
index 28c4fcbe7..d59cdaa48 100644
--- a/app/assets/javascripts/discourse/views/post.js.es6
+++ b/app/assets/javascripts/discourse/views/post.js.es6
@@ -3,6 +3,7 @@ import { number } from 'discourse/lib/formatter';
 import DiscourseURL from 'discourse/lib/url';
 import { default as computed, on } from 'ember-addons/ember-computed-decorators';
 import { fmt } from 'discourse/lib/computed';
+import { decorateLinks as decorateCategoryHashtagLinks } from 'discourse/lib/category-hashtags';
 
 const DAY = 60 * 50 * 1000;
 
@@ -75,6 +76,7 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
   _cookedWasChanged() {
     this.trigger('postViewUpdated', this.$());
     this._insertQuoteControls();
+    this._decorateCategoryHashtagLinks();
   },
 
   mouseUp(e) {
@@ -318,6 +320,7 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
     const $post = this.$(),
           postNumber = this.get('post').get('post_number');
 
+    this._decorateCategoryHashtagLinks();
     this._showLinkCounts();
 
     ScreenTrack.current().track($post.prop('id'), postNumber);
@@ -375,7 +378,12 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
       cooked.unhighlight();
       this._highlighted = false;
     }
-  }.observes('searchService.highlightTerm', 'cooked')
+  }.observes('searchService.highlightTerm', 'cooked'),
+
+  _decorateCategoryHashtagLinks() {
+    const $elems = this.$('.cooked a.hashtag');
+    if ($elems.length > 0) decorateCategoryHashtagLinks($elems);
+  }
 });
 
 export default PostView;
diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss
index 3f5239d1f..ff861bb28 100644
--- a/app/assets/stylesheets/common/components/badges.css.scss
+++ b/app/assets/stylesheets/common/components/badges.css.scss
@@ -30,7 +30,7 @@
       color: $primary !important;
       padding: 3px;
       vertical-align: text-top;
-      margin-top: -3px; //vertical alignment fix
+      margin-top: -2px; //vertical alignment fix
       display: inline-block;
       overflow: hidden;
       text-overflow: ellipsis;
@@ -38,21 +38,18 @@
       .extra-info-wrapper & {
         color: $header-primary !important;
       }
-   }
+    }
 
-   .badge-category-parent-bg, .badge-category-bg {
-     display: inline-block;
-     padding: 1px;
-
-     &:before {
-       content: "\a0";
-     }
-
-   }
+    .badge-category-parent-bg, .badge-category-bg {
+      display: inline-block;
+      padding: 1px;
 
+      &:before {
+        content: "\a0";
+      }
+    }
   }
 
-
   &.bullet { //bullet category style
     display: inline-flex;
     align-items: baseline;
@@ -71,31 +68,30 @@
       .extra-info-wrapper & {
         color: $header-primary !important;
       }
-   }
+    }
 
    .badge-category-parent-bg, .badge-category-bg {
-     width: 10px;
-     height: 10px;
-     margin-right: 5px;
-     display: inline-block;
-     line-height: 1;
+    width: 10px;
+    height: 10px;
+    margin-right: 5px;
+    display: inline-block;
+    line-height: 1;
 
-     &:before {
-       content: "\a0";
-     }
-   }
+    &:before {
+      content: "\a0";
+    }
+  }
 
-   span {
-     &.badge-category-parent-bg { //subcategory style
-       width: 5px;
-       margin-right: 0;
-       & + .badge-category-bg {
-         width: 5px;
-       }
-     }
-   }
-
- }
+  span {
+      &.badge-category-parent-bg { //subcategory style
+        width: 5px;
+        margin-right: 0;
+        & + .badge-category-bg {
+          width: 5px;
+        }
+      }
+    }
+  }
 
 
   &.box {  //box category style (apply custom widths to the wrapper, not the children)
@@ -134,6 +130,59 @@
   }
 }
 
+@mixin cooked-badge-bullet($length, $offset:0px) {
+  .badge-wrapper.bullet {
+    span {
+      position: relative;
+
+      &.badge-category-bg {
+        width: $length;
+        height: $length;
+        top: $offset;
+      }
+
+      &.badge-category-parent-bg {
+        width: $length / 2;
+        height: $length;
+        top: $offset;
+
+        & + .badge-category-bg {
+          width: $length / 2;
+        }
+      }
+    }
+  }
+}
+
+.cooked, .d-editor-preview {
+  p .badge-wrapper.bullet {
+    margin: 0px 2.5px;
+  }
+
+  h1 { @include cooked-badge-bullet(22px) }
+  h2 { @include cooked-badge-bullet(17px) }
+  h3 { @include cooked-badge-bullet(14px) }
+  h4 { @include cooked-badge-bullet(12px) }
+  h5 { @include cooked-badge-bullet(10px, -1.1px) }
+  h6 { @include cooked-badge-bullet(9px, -1.5px) }
+
+  .badge-wrapper.box {
+    span {
+      display: inline;
+    }
+
+    .badge-notification.clicks {
+      display: inline-block;
+      overflow: visible;
+      top: 0px;
+    }
+
+    .badge-category-bg {
+      padding-right: 5px;
+    }
+  }
+}
+
 // Category badge dropdown
 // --------------------------------------------------
 
diff --git a/test/javascripts/lib/category-hashtags-test.js.es6 b/test/javascripts/lib/category-hashtags-test.js.es6
new file mode 100644
index 000000000..dea21dc13
--- /dev/null
+++ b/test/javascripts/lib/category-hashtags-test.js.es6
@@ -0,0 +1,22 @@
+import createStore from 'helpers/create-store';
+import Category from 'discourse/models/category';
+import { findCategoryByHashtagSlug } from "discourse/lib/category-hashtags";
+
+module("lib:category-hashtags");
+
+test('findCategoryByHashtagSlug', () => {
+  const store = createStore();
+
+  const parentCategory = store.createRecord('category', { slug: 'test1' });
+
+  const childCategory = store.createRecord('category', {
+    slug: 'test2', parentCategory: parentCategory
+  });
+
+  sandbox.stub(Category, 'list').returns([parentCategory, childCategory]);
+
+  equal(findCategoryByHashtagSlug('test1'), parentCategory, 'returns the right category');
+  equal(findCategoryByHashtagSlug('test1:test2'), childCategory, 'returns the right category');
+  equal(findCategoryByHashtagSlug('#test1'), parentCategory, 'returns the right category');
+  equal(findCategoryByHashtagSlug('#test1:test2'), childCategory, 'returns the right category');
+});