From c1dbf5c1c421f6e485c0552df0d280739e061270 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan
Date: Mon, 28 Dec 2015 14:28:16 +0800
Subject: [PATCH] FEATURE: Autolinking to category using hashtags.
---
.../components/category-group.js.es6 | 7 +--
.../components/composer-editor.js.es6 | 19 +++++--
.../discourse/components/d-editor.js.es6 | 51 ++++++++++++++++--
.../dialects/category_hashtag_dialect.js | 23 ++++++++
.../discourse/lib/autocomplete.js.es6 | 12 ++++-
.../lib/link-category-hashtags.js.es6 | 53 +++++++++++++++++++
.../javascripts/discourse/lib/markdown.js | 2 +
.../javascripts/discourse/lib/utilities.js | 13 +++++
.../discourse/routes/app-route-map.js.es6 | 1 +
.../routes/discovery-category-with-id.js.es6 | 11 ++++
.../category_hashtags_controller.rb | 14 +++++
app/controllers/list_controller.rb | 10 +++-
app/models/category.rb | 4 ++
config/routes.rb | 3 +-
lib/pretty_text.rb | 10 ++++
.../category_hashtags_controller_spec.rb | 46 ++++++++++++++++
spec/controllers/list_controller_spec.rb | 20 +++++++
spec/models/category_spec.rb | 16 ++++++
test/javascripts/lib/markdown-test.js.es6 | 40 ++++++++++++++
test/javascripts/lib/utilities-test.js.es6 | 24 +++++++++
20 files changed, 365 insertions(+), 14 deletions(-)
create mode 100644 app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js
create mode 100644 app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
create mode 100644 app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6
create mode 100644 app/controllers/category_hashtags_controller.rb
create mode 100644 spec/controllers/category_hashtags_controller_spec.rb
diff --git a/app/assets/javascripts/discourse/components/category-group.js.es6 b/app/assets/javascripts/discourse/components/category-group.js.es6
index 887e4ad7b20..4daca78a652 100644
--- a/app/assets/javascripts/discourse/components/category-group.js.es6
+++ b/app/assets/javascripts/discourse/components/category-group.js.es6
@@ -1,18 +1,19 @@
import { categoryBadgeHTML } from 'discourse/helpers/category-link';
+import Category from 'discourse/models/category';
export default Ember.Component.extend({
_initializeAutocomplete: function() {
const self = this,
template = this.container.lookup('template:category-group-autocomplete.raw'),
- regexp = new RegExp("href=['\"]" + Discourse.getURL('/c/') + "([^'\"]+)");
+ regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`);
this.$('input').autocomplete({
items: this.get('categories'),
single: false,
allowAny: false,
dataSource(term){
- return Discourse.Category.list().filter(function(category){
+ return Category.list().filter(function(category){
const regex = new RegExp(term, "i");
return category.get("name").match(regex) &&
!_.contains(self.get('blacklist') || [], category) &&
@@ -22,7 +23,7 @@ export default Ember.Component.extend({
onChangeItems(items) {
const categories = _.map(items, function(link) {
const slug = link.match(regexp)[1];
- return Discourse.Category.findSingleBySlug(slug);
+ return Category.findSingleBySlug(slug);
});
Em.run.next(() => self.set("categories", categories));
},
diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6
index 51a7a8fe143..9dbd7f25d14 100644
--- a/app/assets/javascripts/discourse/components/composer-editor.js.es6
+++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6
@@ -1,6 +1,7 @@
import userSearch from 'discourse/lib/user-search';
import { default as computed, on } from 'ember-addons/ember-computed-decorators';
import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions';
+import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse/lib/link-category-hashtags';
export default Ember.Component.extend({
classNames: ['wmd-controls'],
@@ -111,13 +112,19 @@ export default Ember.Component.extend({
$preview.scrollTop(desired + 50);
},
- _renderUnseen: function($preview, unseen) {
- fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => {
+ _renderUnseenMentions: function($preview, unseen) {
+ fetchUnseenMentions($preview, unseen).then(() => {
linkSeenMentions($preview, this.siteSettings);
this._warnMentionedGroups($preview);
});
},
+ _renderUnseenCategoryHashtags: function($preview, unseen) {
+ fetchUnseenCategoryHashtags(unseen).then(() => {
+ linkSeenCategoryHashtags($preview);
+ });
+ },
+
_warnMentionedGroups($preview) {
Ember.run.scheduleOnce('afterRender', () => {
this._warnedMentions = this._warnedMentions || [];
@@ -386,11 +393,17 @@ export default Ember.Component.extend({
// Paint mentions
const unseen = linkSeenMentions($preview, this.siteSettings);
if (unseen.length) {
- Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500);
+ Ember.run.debounce(this, this._renderUnseenMentions, $preview, unseen, 500);
}
this._warnMentionedGroups($preview);
+ // Paint category hashtags
+ const unseenHashtags = linkSeenCategoryHashtags($preview);
+ if (unseenHashtags.length) {
+ Ember.run.debounce(this, this._renderUnseenCategoryHashtags, $preview, unseenHashtags, 500);
+ }
+
const post = this.get('composer.post');
let refresh = false;
diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6
index ceaad81bb52..a67b4b8cc3f 100644
--- a/app/assets/javascripts/discourse/components/d-editor.js.es6
+++ b/app/assets/javascripts/discourse/components/d-editor.js.es6
@@ -2,6 +2,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';
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
@@ -175,7 +176,11 @@ export default Ember.Component.extend({
@on('didInsertElement')
_startUp() {
- this._applyEmojiAutocomplete();
+ const container = this.get('container'),
+ $editorInput = this.$('.d-editor-input');
+
+ this._applyEmojiAutocomplete(container, $editorInput);
+ this._applyCategoryHashtagAutocomplete(container, $editorInput);
loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true));
@@ -243,14 +248,52 @@ export default Ember.Component.extend({
Ember.run.debounce(this, this._updatePreview, 30);
},
- _applyEmojiAutocomplete() {
+ _applyCategoryHashtagAutocomplete(container, $editorInput) {
+ const template = container.lookup('template:category-group-autocomplete.raw');
+
+ $editorInput.autocomplete({
+ template: template,
+ key: '#',
+ transformComplete(category) {
+ return category.get('slug');
+ },
+ dataSource(term) {
+ return Category.list().filter(category => {
+ const regexp = new RegExp(term, 'i');
+ return category.get('name').match(regexp);
+ });
+ },
+ triggerRule(textarea, opts) {
+ const result = Discourse.Utilities.caretRowCol(textarea);
+ const row = result.rowNum;
+ var col = result.colNum;
+ var line = textarea.value.split("\n")[row - 1];
+
+ if (opts && opts.backSpace) {
+ col = col - 1;
+ line = line.slice(0, line.length - 1);
+
+ // Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
+ if (/^#{1}\w+/.test(line)) return false;
+ }
+
+ if (col < 6) {
+ // Don't trigger autocomplete when ATX-style headers are used
+ return (line.slice(0, col) !== "#".repeat(col));
+ } else {
+ return true;
+ }
+ }
+ });
+ },
+
+ _applyEmojiAutocomplete(container, $editorInput) {
if (!this.siteSettings.enable_emoji) { return; }
- const container = this.container;
const template = container.lookup('template:emoji-selector-autocomplete.raw');
const self = this;
- this.$('.d-editor-input').autocomplete({
+ $editorInput.autocomplete({
template: template,
key: ":",
diff --git a/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js b/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js
new file mode 100644
index 00000000000..e428b7ed5f3
--- /dev/null
+++ b/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js
@@ -0,0 +1,23 @@
+/**
+ Supports Discourse's category hashtags (#category-slug) for automatically
+ generating a link to the category.
+**/
+Discourse.Dialect.inlineRegexp({
+ start: '#',
+ matcher: /^#([A-Za-z0-9][A-Za-z0-9\-]{0,40}[A-Za-z0-9])/,
+ spaceOrTagBoundary: true,
+
+ emitter: function(matches) {
+ var slug = matches[1],
+ hashtag = matches[0],
+ attributeClass = 'hashtag',
+ categoryHashtagLookup = this.dialect.options.categoryHashtagLookup,
+ result = categoryHashtagLookup && categoryHashtagLookup(slug);
+
+ if (result && result[0] === "category") {
+ return ['a', { class: attributeClass, href: result[1] }, hashtag];
+ } else {
+ return ['span', { class: attributeClass }, hashtag];
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6
index 78690f624b5..f1b24853ab1 100644
--- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6
+++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6
@@ -282,6 +282,14 @@ export default function(options) {
}, 50);
});
+ const checkTriggerRule = (opts) => {
+ if (options.triggerRule) {
+ return options.triggerRule(me[0], opts);
+ } else {
+ return true;
+ }
+ };
+
$(this).on('keypress.autocomplete', function(e) {
var caretPosition, term;
@@ -289,7 +297,7 @@ export default function(options) {
if (options.key && e.which === options.key.charCodeAt(0)) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
var prevChar = me.val().charAt(caretPosition - 1);
- if (!prevChar || allowedLettersRegex.test(prevChar)) {
+ if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = caretPosition;
updateAutoComplete(options.dataSource(""));
}
@@ -343,7 +351,7 @@ export default function(options) {
stopFound = prev === options.key;
if (stopFound) {
prev = me[0].value[c - 1];
- if (!prev || allowedLettersRegex.test(prev)) {
+ if (checkTriggerRule({ backSpace: true }) && (!prev || allowedLettersRegex.test(prev))) {
completeStart = c;
caretPosition = completeEnd = initial;
term = me[0].value.substring(c + 1, initial);
diff --git a/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6 b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
new file mode 100644
index 00000000000..446e1aa53a2
--- /dev/null
+++ b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6
@@ -0,0 +1,53 @@
+const validCategoryHashtags = {};
+const checkedCategoryHashtags = [];
+const testedKey = 'tested';
+const testedClass = `hashtag-${testedKey}`;
+
+function replaceSpan($elem, categorySlug, categoryLink) {
+ $elem.replaceWith(`#${categorySlug}`);
+}
+
+function updateFound($hashtags, categorySlugs) {
+ Ember.run.schedule('afterRender', () => {
+ $hashtags.each((index, hashtag) => {
+ const categorySlug = categorySlugs[index];
+ const link = validCategoryHashtags[categorySlug];
+ const $hashtag = $(hashtag);
+
+ if (link) {
+ replaceSpan($hashtag, categorySlug, link);
+ } else if (checkedCategoryHashtags.indexOf(categorySlug) !== -1) {
+ $hashtag.addClass(testedClass);
+ }
+ });
+ });
+};
+
+export function linkSeenCategoryHashtags($elem) {
+ const $hashtags = $(`span.hashtag:not(.${testedClass})`, $elem);
+ const unseen = [];
+
+ if ($hashtags.length) {
+ const categorySlugs = $hashtags.map((_, hashtag) => $(hashtag).text().substr(1));
+ if (categorySlugs.length) {
+ _.uniq(categorySlugs).forEach((categorySlug) => {
+ if (checkedCategoryHashtags.indexOf(categorySlug) === -1) {
+ unseen.push(categorySlug);
+ }
+ });
+ }
+ updateFound($hashtags, categorySlugs);
+ }
+
+ return unseen;
+};
+
+export function fetchUnseenCategoryHashtags(categorySlugs) {
+ return Discourse.ajax("/category_hashtags/check", { data: { category_slugs: categorySlugs } })
+ .then((response) => {
+ response.valid.forEach((category) => {
+ validCategoryHashtags[category.slug] = category.url;
+ });
+ checkedCategoryHashtags.push.apply(checkedCategoryHashtags, categorySlugs);
+ });
+}
diff --git a/app/assets/javascripts/discourse/lib/markdown.js b/app/assets/javascripts/discourse/lib/markdown.js
index 4b9e3ce1e8f..29616a7e27b 100644
--- a/app/assets/javascripts/discourse/lib/markdown.js
+++ b/app/assets/javascripts/discourse/lib/markdown.js
@@ -239,6 +239,7 @@ Discourse.Markdown.whiteListTag('a', 'class', 'attachment');
Discourse.Markdown.whiteListTag('a', 'class', 'onebox');
Discourse.Markdown.whiteListTag('a', 'class', 'mention');
Discourse.Markdown.whiteListTag('a', 'class', 'mention-group');
+Discourse.Markdown.whiteListTag('a', 'class', 'hashtag');
Discourse.Markdown.whiteListTag('a', 'target', '_blank');
Discourse.Markdown.whiteListTag('a', 'rel', 'nofollow');
@@ -251,6 +252,7 @@ Discourse.Markdown.whiteListTag('div', 'class', 'title');
Discourse.Markdown.whiteListTag('div', 'class', 'quote-controls');
Discourse.Markdown.whiteListTag('span', 'class', 'mention');
+Discourse.Markdown.whiteListTag('span', 'class', 'hashtag');
Discourse.Markdown.whiteListTag('aside', 'class', 'quote');
Discourse.Markdown.whiteListTag('aside', 'data-*');
diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js
index 72cd08816b5..d3da0bcb022 100644
--- a/app/assets/javascripts/discourse/lib/utilities.js
+++ b/app/assets/javascripts/discourse/lib/utilities.js
@@ -143,6 +143,19 @@ Discourse.Utilities = {
return String(text).trim();
},
+ // Determine the row and col of the caret in an element
+ caretRowCol: function(el) {
+ var caretPosition = Discourse.Utilities.caretPosition(el);
+ var rows = el.value.slice(0, caretPosition).split("\n");
+ var rowNum = rows.length;
+
+ var colNum = caretPosition - rows.splice(0, rowNum - 1).reduce(function(sum, row) {
+ return sum + row.length + 1;
+ }, 0);
+
+ return { rowNum: rowNum, colNum: colNum};
+ },
+
// Determine the position of the caret in an element
caretPosition: function(el) {
var r, rc, re;
diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
index 6aa420a88c4..f24ee0ef3dc 100644
--- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6
+++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
@@ -42,6 +42,7 @@ export default function() {
this.route('parentCategory', { path: '/c/:slug' });
this.route('categoryNone', { path: '/c/:slug/none' });
this.route('category', { path: '/c/:parentSlug/:slug' });
+ this.route('categoryWithID', { path: '/c/:parentSlug/:slug/:id' });
// homepage
this.route(Discourse.Utilities.defaultHomepage(), { path: '/' });
diff --git a/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 b/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6
new file mode 100644
index 00000000000..c36b4bae792
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6
@@ -0,0 +1,11 @@
+import Category from 'discourse/models/category';
+
+export default Discourse.Route.extend({
+ model: function(params) {
+ return Category.findById(params.id);
+ },
+
+ redirect: function(model) {
+ this.transitionTo(`/c/${Category.slugFor(model)}`);
+ }
+});
diff --git a/app/controllers/category_hashtags_controller.rb b/app/controllers/category_hashtags_controller.rb
new file mode 100644
index 00000000000..6e78a92ec73
--- /dev/null
+++ b/app/controllers/category_hashtags_controller.rb
@@ -0,0 +1,14 @@
+class CategoryHashtagsController < ApplicationController
+ before_filter :ensure_logged_in
+
+ def check
+ category_slugs = params[:category_slugs]
+ category_slugs.each(&:downcase!)
+
+ valid_categories = Category.secured(guardian).where(slug: category_slugs).map do |category|
+ { slug: category.slug, url: category.url_with_id }
+ end.compact
+
+ render json: { valid: valid_categories }
+ end
+end
diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb
index 07fec8d6a3b..cd59e309877 100644
--- a/app/controllers/list_controller.rb
+++ b/app/controllers/list_controller.rb
@@ -224,14 +224,22 @@ class ListController < ApplicationController
def set_category
slug_or_id = params.fetch(:category)
parent_slug_or_id = params[:parent_category]
+ id = params[:id].to_i
parent_category_id = nil
if parent_slug_or_id.present?
parent_category_id = Category.query_parent_category(parent_slug_or_id)
- redirect_or_not_found and return if parent_category_id.blank?
+ redirect_or_not_found and return if parent_category_id.blank? && !id
end
@category = Category.query_category(slug_or_id, parent_category_id)
+
+ # Redirect if we have `/c/:parent_category/:category/:id`
+ if id
+ category = Category.find_by_id(id)
+ (redirect_to category.url, status: 301) && return if category
+ end
+
redirect_or_not_found and return if !@category
@description_meta = @category.description_text
diff --git a/app/models/category.rb b/app/models/category.rb
index bd5e85914fb..80b2a1a5c7d 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -416,6 +416,10 @@ SQL
url
end
+ def url_with_id
+ self.parent_category ? "#{url}/#{self.id}" : "#{Discourse.base_uri}/c/#{self.id}-#{self.slug}"
+ end
+
# If the name changes, try and update the category definition topic too if it's
# an exact match
def rename_category_definition
diff --git a/config/routes.rb b/config/routes.rb
index 638396dd327..2859b5bffc7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -425,11 +425,12 @@ Discourse::Application.routes.draw do
get "c/:parent_category/:category.rss" => "list#category_feed", format: :rss
get "c/:category" => "list#category_latest"
get "c/:category/none" => "list#category_none_latest"
- get "c/:parent_category/:category" => "list#parent_category_category_latest"
+ get "c/:parent_category/:category/(:id)" => "list#parent_category_category_latest", constraints: { id: /\d+/ }
get "c/:category/l/top" => "list#category_top", as: "category_top"
get "c/:category/none/l/top" => "list#category_none_top", as: "category_none_top"
get "c/:parent_category/:category/l/top" => "list#parent_category_category_top", as: "parent_category_category_top"
+ get "category_hashtags/check" => "category_hashtags#check"
TopTopic.periods.each do |period|
get "top/#{period}" => "list#top_#{period}"
diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb
index aefd4bed006..126f3358c2e 100644
--- a/lib/pretty_text.rb
+++ b/lib/pretty_text.rb
@@ -48,6 +48,15 @@ module PrettyText
end
end
+ def category_hashtag_lookup(category_slug)
+ if category_slug
+ category = Category.find_by_slug(category_slug)
+ return ['category', category.url_with_id] if category
+ else
+ nil
+ end
+ end
+
def get_topic_info(topic_id)
return unless Fixnum === topic_id
# TODO this only handles public topics, secured one do not get this
@@ -207,6 +216,7 @@ module PrettyText
context.eval("Discourse.Emoji.applyCustomEmojis();")
context.eval('opts["mentionLookup"] = function(u){return helpers.mention_lookup(u);}')
+ context.eval('opts["categoryHashtagLookup"] = function(c){return helpers.category_hashtag_lookup(c);}')
context.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({size: "tiny", avatarTemplate: helpers.avatar_template(p)});}')
context.eval('opts["getTopicInfo"] = function(i){return helpers.get_topic_info(i)};')
baked = context.eval('Discourse.Markdown.markdownConverter(opts).makeHtml(raw)')
diff --git a/spec/controllers/category_hashtags_controller_spec.rb b/spec/controllers/category_hashtags_controller_spec.rb
new file mode 100644
index 00000000000..01d5eeb74f1
--- /dev/null
+++ b/spec/controllers/category_hashtags_controller_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+describe CategoryHashtagsController do
+ describe "check" do
+ describe "logged in" do
+ before do
+ log_in(:user)
+ end
+
+ it 'only returns the categories that are valid' do
+ category = Fabricate(:category)
+ xhr :get, :check, category_slugs: [category.slug, 'none']
+
+ expect(JSON.parse(response.body)).to eq(
+ { "valid" => [{ "slug" => category.slug, "url" => category.url_with_id }] }
+ )
+ end
+
+ it 'does not return restricted categories for a normal user' do
+ group = Fabricate(:group)
+ private_category = Fabricate(:private_category, group: group)
+ xhr :get, :check, category_slugs: [private_category.slug]
+
+ expect(JSON.parse(response.body)).to eq({ "valid" => [] })
+ end
+
+ it 'returns restricted categories for an admin' do
+ admin = log_in(:admin)
+ group = Fabricate(:group)
+ group.add(admin)
+ private_category = Fabricate(:private_category, group: group)
+ xhr :get, :check, category_slugs: [private_category.slug]
+
+ expect(JSON.parse(response.body)).to eq(
+ { "valid" => [{ "slug" => private_category.slug, "url" => private_category.url_with_id }] }
+ )
+ end
+ end
+
+ describe "not logged in" do
+ it 'raises an exception' do
+ expect { xhr :get, :check, category_slugs: [] }.to raise_error(Discourse::NotLoggedIn)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb
index 0415074b903..59c20da024f 100644
--- a/spec/controllers/list_controller_spec.rb
+++ b/spec/controllers/list_controller_spec.rb
@@ -83,6 +83,26 @@ describe ListController do
it { is_expected.to respond_with(:success) }
end
+ context 'with a link that has a parent slug, slug and id in its path' do
+ let(:child_category) { Fabricate(:category, parent_category: category) }
+
+ context "with valid slug" do
+ before do
+ xhr :get, :category_latest, parent_category: category.slug, category: child_category.slug, id: child_category.id
+ end
+
+ it { is_expected.to redirect_to(child_category.url) }
+ end
+
+ context "with invalid slug" do
+ before do
+ xhr :get, :category_latest, parent_category: 'random slug', category: 'random slug', id: child_category.id
+ end
+
+ it { is_expected.to redirect_to(child_category.url) }
+ end
+ end
+
context 'another category exists with a number at the beginning of its name' do
# One category has another category's id at the beginning of its name
let!(:other_category) { Fabricate(:category, name: "#{category.id} name") }
diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb
index f466be73c65..ff253badbb8 100644
--- a/spec/models/category_spec.rb
+++ b/spec/models/category_spec.rb
@@ -503,6 +503,22 @@ describe Category do
end
end
+ describe "#url_with_id" do
+ let(:category) { Fabricate(:category, name: 'cats') }
+
+ it "includes the id in the URL" do
+ expect(category.url_with_id).to eq("/c/#{category.id}-cats")
+ end
+
+ context "child category" do
+ let(:child_category) { Fabricate(:category, parent_category_id: category.id, name: 'dogs') }
+
+ it "includes the id in the URL" do
+ expect(child_category.url_with_id).to eq("/c/cats/dogs/#{child_category.id}")
+ end
+ end
+ end
+
describe "uncategorized" do
let(:cat) { Category.where(id: SiteSetting.uncategorized_category_id).first }
diff --git a/test/javascripts/lib/markdown-test.js.es6 b/test/javascripts/lib/markdown-test.js.es6
index 818b7551411..fde64778ba6 100644
--- a/test/javascripts/lib/markdown-test.js.es6
+++ b/test/javascripts/lib/markdown-test.js.es6
@@ -289,6 +289,46 @@ test("Mentions", function() {
"it allows mentions within HTML tags");
});
+test("Category hashtags", () => {
+ var alwaysTrue = { categoryHashtagLookup: (function() { return ["category", "http://test.discourse.org/category-hashtag"]; }) };
+
+ cookedOptions("Check out #category-hashtag", alwaysTrue,
+ "Check out #category-hashtag
",
+ "it translates category hashtag into links");
+
+ cooked("Check out #category-hashtag",
+ "Check out #category-hashtag
",
+ "it does not translate category hashtag into links if it is not a valid category hashtag");
+
+ cookedOptions("[#category-hashtag](http://www.test.com)", alwaysTrue,
+ "#category-hashtag
",
+ "it does not translate category hashtag within links");
+
+ cooked("```\n# #category-hashtag\n```",
+ "# #category-hashtag
",
+ "it does not translate category hashtags to links in code blocks");
+
+ cooked("># #category-hashtag\n",
+ "#category-hashtag
",
+ "it handles category hashtags in simple quotes");
+
+ cooked("# #category-hashtag",
+ "#category-hashtag
",
+ "it works within ATX-style headers");
+
+ cooked("don't `#category-hashtag`",
+ "don't #category-hashtag
",
+ "it does not mention in an inline code block");
+
+ cooked("test #hashtag1/#hashtag2",
+ "test #hashtag1/#hashtag2
",
+ "it does not convert category hashtag not bounded by spaces");
+
+ cooked("#category-hashtag",
+ "#category-hashtag
",
+ "it works between HTML tags");
+});
+
test("Heading", function() {
cooked("**Bold**\n----------", "Bold
", "It will bold the heading");
diff --git a/test/javascripts/lib/utilities-test.js.es6 b/test/javascripts/lib/utilities-test.js.es6
index 0a8ebd63640..e5e6eba1e81 100644
--- a/test/javascripts/lib/utilities-test.js.es6
+++ b/test/javascripts/lib/utilities-test.js.es6
@@ -158,3 +158,27 @@ test("defaultHomepage", function() {
Discourse.SiteSettings.top_menu = "latest|top|hot";
equal(utils.defaultHomepage(), "latest", "default homepage is the first item in the top_menu site setting");
});
+
+test("caretRowCol", () => {
+ var textarea = document.createElement('textarea');
+ const content = document.createTextNode("01234\n56789\n012345");
+ textarea.appendChild(content);
+ textarea.setAttribute('id', 'test');
+ document.body.appendChild(textarea);
+
+ const assertResult = (setCaretPos, expectedRowNum, expectedColNum) => {
+ Discourse.Utilities.setCaretPosition(textarea, setCaretPos);
+
+ const result = Discourse.Utilities.caretRowCol(textarea);
+ equal(result.rowNum, expectedRowNum, "returns the right row of the caret");
+ equal(result.colNum, expectedColNum, "returns the right col of the caret");
+ };
+
+ assertResult(0, 1, 0);
+ assertResult(5, 1, 5);
+ assertResult(6, 2, 0);
+ assertResult(11, 2, 5);
+ assertResult(14, 3, 2);
+
+ document.body.removeChild(textarea);
+});