diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index d3c1e96d2eb..aba22cf7284 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -13,7 +13,8 @@ export default Ember.Component.extend({ 'composer.canEditTitle:edit-title', 'composer.createdPost:created-post', 'composer.creatingTopic:topic', - 'composer.whisper:composing-whisper'], + 'composer.whisper:composing-whisper', + 'composer.showComposerEditor::topic-featured-link-only'], @computed('composer.composeState') composeState(composeState) { @@ -27,7 +28,7 @@ export default Ember.Component.extend({ this.appEvents.trigger("composer:resized"); }, - @observes('composeState', 'composer.action') + @observes('composeState', 'composer.action', 'composer.canEditTopicFeaturedLink') resize() { Ember.run.scheduleOnce('afterRender', () => { if (!this.element || this.isDestroying || this.isDestroyed) { return; } diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 42550be8803..e94852cf3ec 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -21,6 +21,9 @@ export default Ember.Controller.extend(ModalFunctionality, { if (this.site.mobileView) { this.set("viewMode", "inline"); } }.on("init"), + previousFeaturedLink: Em.computed.alias('model.featured_link_changes.previous'), + currentFeaturedLink: Em.computed.alias('model.featured_link_changes.current'), + previousTagChanges: customTagArray('model.tags_changes.previous'), currentTagChanges: customTagArray('model.tags_changes.current'), diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 377aebd76d5..4b9650fd98c 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -160,6 +160,14 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return post => this.postSelected(post); }.property(), + @computed('model.isPrivateMessage', 'model.category.id') + canEditTopicFeaturedLink(isPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + @computed('model.isPrivateMessage') canEditTags(isPrivateMessage) { return !isPrivateMessage && this.site.get('can_tag_topics'); diff --git a/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 new file mode 100644 index 00000000000..686599e2b1f --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 @@ -0,0 +1,6 @@ +import { registerUnbound } from 'discourse-common/lib/helpers'; +import renderTopicFeaturedLink from 'discourse/lib/render-topic-featured-link'; + +export default registerUnbound('topic-featured-link', function(topic, params) { + return new Handlebars.SafeString(renderTopicFeaturedLink(topic, params)); +}); diff --git a/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 new file mode 100644 index 00000000000..c8c3d640f80 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 @@ -0,0 +1,46 @@ +import { extractDomainFromUrl } from 'discourse/lib/utilities'; +import { h } from 'virtual-dom'; + +const _decorators = []; + +export function addFeaturedLinkMetaDecorator(decorator) { + _decorators.push(decorator); +} + +function extractLinkMeta(topic) { + const href = topic.featured_link, target = Discourse.SiteSettings.open_topic_featured_link_in_external_window ? '_blank' : ''; + if (!href) { return; } + + let domain = extractDomainFromUrl(href); + if (!domain) { return; } + + // www appears frequently, so we truncate it + if (domain && domain.substr(0, 4) === 'www.') { + domain = domain.substring(4); + } + + const meta = { target, href, domain, rel: 'nofollow' }; + if (_decorators.length) { + _decorators.forEach(cb => cb(meta)); + } + return meta; +} + +export default function renderTopicFeaturedLink(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return `${meta.domain}`; + } else { + return ''; + } +}; + +export function topicFeaturedLinkNode(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return h('a.topic-featured-link', { + attributes: { href: meta.href, rel: meta.rel, target: meta.target } + }, meta.domain); + } +} + diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index b77a4abdf33..fc56790194d 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -169,6 +169,18 @@ const Category = RestModel.extend({ @computed("id") isUncategorizedCategory(id) { return id === Discourse.Site.currentProp("uncategorized_category_id"); + }, + + @computed('custom_fields.topic_featured_link_allowed') + topicFeaturedLinkAllowed: { + get(allowed) { + return allowed === "true"; + }, + set(value) { + value = value ? "true" : "false"; + this.set("custom_fields.topic_featured_link_allowed", value); + return value; + } } }); diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index e782c4074ae..52d8cde6aef 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -32,13 +32,15 @@ const CLOSED = 'closed', target_usernames: 'targetUsernames', typing_duration_msecs: 'typingTime', composer_open_duration_msecs: 'composerTime', - tags: 'tags' + tags: 'tags', + featured_link: 'featuredLink' }, _edit_topic_serializer = { title: 'topic.title', categoryId: 'topic.category.id', - tags: 'topic.tags' + tags: 'topic.tags', + featuredLink: 'topic.featured_link' }; const Composer = RestModel.extend({ @@ -136,6 +138,14 @@ const Composer = RestModel.extend({ canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), + @computed('canEditTitle', 'creatingPrivateMessage', 'categoryId') + canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || !canEditTitle || creatingPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + // Determine the appropriate title for this action actionTitle: function() { const topic = this.get('topic'); @@ -180,6 +190,10 @@ const Composer = RestModel.extend({ }.property('action', 'post', 'topic', 'topic.title'), + @computed('canEditTopicFeaturedLink') + showComposerEditor(canEditTopicFeaturedLink) { + return canEditTopicFeaturedLink ? !this.siteSettings.topic_featured_link_onebox : true; + }, // whether to disable the post button cantSubmitPost: function() { @@ -269,11 +283,12 @@ const Composer = RestModel.extend({ } }.property('privateMessage'), - missingReplyCharacters: function() { - const postType = this.get('post.post_type'); - if (postType === this.site.get('post_types.small_action')) { return 0; } - return this.get('minimumPostLength') - this.get('replyLength'); - }.property('minimumPostLength', 'replyLength'), + @computed('minimumPostLength', 'replyLength', 'canEditTopicFeaturedLink') + missingReplyCharacters(minimumPostLength, replyLength, canEditTopicFeaturedLink) { + if (this.get('post.post_type') === this.site.get('post_types.small_action') || + canEditTopicFeaturedLink && this.siteSettings.topic_featured_link_onebox) { return 0; } + return minimumPostLength - replyLength; + }, /** Minimum number of characters for a post body to be valid. @@ -492,6 +507,14 @@ const Composer = RestModel.extend({ save(opts) { if (!this.get('cantSubmitPost')) { + + // change category may result in some effect for topic featured link + if (this.get('canEditTopicFeaturedLink')) { + if (this.siteSettings.topic_featured_link_onebox) { this.set('reply', null); } + } else { + this.set('featuredLink', null); + } + return this.get('editingPost') ? this.editPost(opts) : this.createPost(opts); } }, @@ -512,7 +535,8 @@ const Composer = RestModel.extend({ stagedPost: false, typingTime: 0, composerOpened: null, - composerTotalOpened: 0 + composerTotalOpened: 0, + featuredLink: null }); }, diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs index 72883cba5ad..790e61e9390 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -19,6 +19,17 @@ +{{#if siteSettings.topic_featured_link_enabled}} + + + + {{input type="checkbox" checked=category.topicFeaturedLinkAllowed}} + {{i18n 'category.topic_featured_link_allowed'}} + + + +{{/if}} + {{i18n "category.sort_order"}} diff --git a/app/assets/javascripts/discourse/templates/components/topic-category.hbs b/app/assets/javascripts/discourse/templates/components/topic-category.hbs index c3114225607..cf717a09922 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-category.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-category.hbs @@ -2,12 +2,16 @@ {{bound-category-link topic.category.parentCategory}} {{/if}} {{bound-category-link topic.category hideParent=true}} -{{#if siteSettings.tagging_enabled}} - - {{#each topic.tags as |t|}} - {{discourse-tag t}} - {{/each}} - -{{/if}} - + + {{#if siteSettings.tagging_enabled}} + + {{#each topic.tags as |t|}} + {{discourse-tag t}} + {{/each}} + + {{/if}} + {{#if siteSettings.topic_featured_link_enabled}} + {{topic-featured-link topic}} + {{/if}} + {{plugin-outlet "topic-category"}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index ffed59d239c..bf40be927c1 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -80,9 +80,13 @@ {{/if}} {{render "additional-composer-buttons" model}} {{/if}} + {{#if model.canEditTopicFeaturedLink}} + + {{text-field tabindex="4" type="url" value=model.featuredLink id='topic-featured-link' placeholderKey="composer.topic_featured_link_placeholder"}} + + {{/if}} {{/if}} - {{plugin-outlet "composer-fields"}} diff --git a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs index bc21dfd6a7d..da825c4774b 100644 --- a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs @@ -7,6 +7,9 @@ {{raw "topic-status" topic=topic}} {{topic-link topic}} + {{#if topic.featured_link}} + {{topic-featured-link topic}} + {{/if}} {{plugin-outlet "topic-list-after-title"}} {{#if showTopicPostBadges}} {{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}} diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index eac25480d52..ce860fefcbf 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -86,6 +86,13 @@ {{/each}} {{/if}} + {{#if model.featured_link_changes}} + + {{model.featured_link_changes.previous}} + → + {{model.featured_link_changes.current}} + + {{/if}} {{plugin-outlet "post-revisions"}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 65c0f3f6a81..aed888a39f8 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -25,6 +25,9 @@ {{category-chooser valueAttribute="id" value=buffered.category_id}} {{/if}} + {{#if canEditTopicFeaturedLink}} + {{text-field type="url" value=buffered.featured_link id='topic-featured-link' placeholderKey="composer.topic_featured_link_placeholder"}} + {{/if}} {{#if canEditTags}} {{tag-chooser tags=buffered.tags categoryId=buffered.category_id}} diff --git a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 index 8704830bbde..a3d5ad585d7 100644 --- a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 @@ -4,6 +4,7 @@ import { iconNode } from 'discourse/helpers/fa-icon-node'; import DiscourseURL from 'discourse/lib/url'; import RawHtml from 'discourse/widgets/raw-html'; import { tagNode } from 'discourse/lib/render-tag'; +import { topicFeaturedLinkNode } from 'discourse/lib/render-topic-featured-link'; export default createWidget('header-topic-info', { tagName: 'div.extra-info-wrapper', @@ -44,12 +45,19 @@ export default createWidget('header-topic-info', { title.push(this.attach('category-link', { category })); } + const extra = []; if (this.siteSettings.tagging_enabled) { const tags = topic.get('tags') || []; if (tags.length) { - title.push(h('div.list-tags', tags.map(tagNode))); + extra.push(h('div.list-tags', tags.map(tagNode))); } } + if (this.siteSettings.topic_featured_link_enabled) { + extra.push(topicFeaturedLinkNode(attrs.topic)); + } + if (extra) { + title.push(h('div.topic-header-extra', extra)); + } } const contents = h('div.title-wrapper', title); diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index dfadbd6fb80..4e53da0fc26 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -187,6 +187,10 @@ div.ac-wrap { } } +#reply-control.topic-featured-link-only.open { + .wmd-controls { display: none; } +} + #cancel-file-upload { font-size: 1.6em; } diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 70b971c4229..2a32c29d3b0 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -27,18 +27,11 @@ } } -.extra-info-wrapper { - .list-tags { - padding-top: 5px; - } - - .discourse-tag { - -webkit-animation: fadein .7s; - animation: fadein .7s; - } +.topic-header-extra .discourse-tag { + -webkit-animation: fadein .7s; + animation: fadein .7s; } - .add-tags .select2 { margin: 0; } @@ -139,8 +132,8 @@ $tag-color: scale-color($primary, $lightness: 40%); header .discourse-tag {color: $tag-color } .list-tags { + margin-right: 3px; display: inline; - margin: 0 0 0 5px; font-size: 0.857em; } @@ -171,24 +164,6 @@ header .discourse-tag {color: $tag-color } left: auto; } -.bullet + .list-tags { - display: block; - line-height: 15px; -} - -.bar + .list-tags { - line-height: 1.25; - .discourse-tag { - vertical-align: middle; - } -} - -.box + .list-tags { - display: inline-block; - margin: 5px 0 0 5px; - padding-top: 2px; -} - .tag-sort-options { margin-bottom: 20px; a { diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 38e17a48da1..def557e03e7 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -9,6 +9,10 @@ .badge-wrapper { float: left; } + + a.topic-featured-link { + display: inline-block; + } } a.badge-category { @@ -47,7 +51,7 @@ display: inline; } -#suggested-topics h3 .badge-wrapper.bullet span.badge-category, { +#suggested-topics h3 .badge-wrapper.bullet span.badge-category { // Override vertical-align: text-top from `badges.css.scss` vertical-align: baseline; line-height: 1.2; @@ -133,3 +137,18 @@ } } } + +a.topic-featured-link { + display: inline-block; + text-transform: lowercase; + color: #858585; + font-size: 0.875rem; + + &::before { + position: relative; + top: 0.1em; + padding-right: 3px; + font-family: FontAwesome; + content: "\f08e"; + } +} diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index ed145348943..8273d97c8b4 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -133,6 +133,10 @@ } } +.extra-info-wrapper .title-wrapper .badge-wrapper.bar { + margin-top: 6px; +} + .autocomplete, td.category { .badge-wrapper { max-width: 230px; diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 738fb583e83..fbfcd4e874c 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -298,6 +298,11 @@ background-color: dark-light-diff($primary, $secondary, 90%, -60%); } } + #topic-featured-link { + padding: 7px 10px; + margin: 6px 10px 3px 0; + width: 400px; + } .d-editor-input:disabled { background-color: dark-light-diff($primary, $secondary, 90%, -60%); } @@ -465,6 +470,10 @@ } } +#reply-control.topic-featured-link-only.open { + height: 200px; +} + .control-row.reply-area { padding-left: 20px; padding-right: 20px; diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index a53b90f9ba6..2c0c57e4fd1 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -505,13 +505,13 @@ video { .extra-info-wrapper { overflow: hidden; - .badge-wrapper, i, .topic-link { + .badge-wrapper, i, .topic-link { -webkit-animation: fadein .7s; animation: fadein .7s; } .topic-statuses { - i { color: $header_primary; } + i { color: $header_primary; } i.fa-envelope { color: $danger; } .unpinned { color: $header_primary; } } @@ -523,6 +523,26 @@ video { overflow: hidden; text-overflow: ellipsis; } + + .topic-header-extra { + margin: 0 0 0 5px; + padding-top: 5px; + } +} + +.bullet + .topic-header-extra { + display: block; + line-height: 12px; +} + +.bar + .topic-header-extra { + line-height: 1.25; +} + +.box + .topic-header-extra { + display: inline-block; + margin: 0 0 0 5px; + padding-top: 5px; } /* default docked header CSS for all topics, including those without categories */ diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b2c3c85a3fb..7f68a648eff 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -574,7 +574,6 @@ class PostsController < ApplicationController end - params.require(:raw) result = params.permit(*permitted).tap do |whitelisted| whitelisted[:image_sizes] = params[:image_sizes] # TODO this does not feel right, we should name what meta_data is allowed diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e3689036be3..e13539e6471 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -282,4 +282,15 @@ module ApplicationHelper result.html_safe end + def topic_featured_link_domain(link) + begin + uri = URI.encode(link) + uri = URI.parse(uri) + uri = URI.parse("http://#{uri}") if uri.scheme.nil? + host = uri.host.downcase + host.start_with?('www.') ? host[4..-1] : host + rescue + '' + end + end end diff --git a/app/models/topic.rb b/app/models/topic.rb index 567f0d4e6b5..a5117113238 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -7,6 +7,7 @@ require_dependency 'text_cleaner' require_dependency 'archetype' require_dependency 'html_prettify' require_dependency 'discourse_tagging' +require_dependency 'discourse_featured_link' class Topic < ActiveRecord::Base include ActionView::Helpers::SanitizeHelper @@ -73,6 +74,10 @@ class Topic < ActiveRecord::Base (!t.user_id || !t.user.staff?) } + validates :featured_link, allow_nil: true, format: URI::regexp(%w(http https)) + validate if: :featured_link do + errors.add(:featured_link, :invalid_category) unless Guardian.new.can_edit_featured_link?(category_id) + end before_validation do self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty? @@ -378,6 +383,14 @@ class Topic < ActiveRecord::Base featured_topic_ids ? topics.where("topics.id NOT IN (?)", featured_topic_ids) : topics end + def featured_link + custom_fields[DiscourseFeaturedLink::CUSTOM_FIELD_NAME] + end + + def featured_link=(link) + custom_fields[DiscourseFeaturedLink::CUSTOM_FIELD_NAME] = link.strip + end + def meta_data=(data) custom_fields.replace(data) end diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index e0b3fd8353b..f758a358afb 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -1,4 +1,5 @@ require_dependency 'avatar_lookup' +require_dependency 'discourse_featured_link' class TopicList include ActiveModel::Serialization @@ -27,6 +28,7 @@ class TopicList end preloaded_custom_fields << DiscourseTagging::TAGS_FIELD_NAME if SiteSetting.tagging_enabled + preloaded_custom_fields << DiscourseFeaturedLink::CUSTOM_FIELD_NAME if SiteSetting.topic_featured_link_enabled end def tags diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 4df9012e8d3..8ff8b1826e3 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -193,6 +193,10 @@ class PostRevisionSerializer < ApplicationSerializer end end + if SiteSetting.topic_featured_link_enabled + latest_modifications["featured_link"] = [post.topic.featured_link] + end + if SiteSetting.tagging_enabled latest_modifications["tags"] = [post.topic.tags.map(&:name)] end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 718cb1221aa..d43c165a96e 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -23,7 +23,8 @@ class SiteSerializer < ApplicationSerializer :can_tag_topics, :tags_filter_regexp, :top_tags, - :wizard_required + :wizard_required, + :topic_featured_link_allowed_category_ids has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :trust_levels, embed: :objects @@ -121,4 +122,12 @@ class SiteSerializer < ApplicationSerializer def include_wizard_required? Wizard.user_requires_completion?(scope.user) end + + def include_topic_featured_link_allowed_category_ids? + SiteSetting.topic_featured_link_enabled + end + + def topic_featured_link_allowed_category_ids + scope.topic_featured_link_allowed_category_ids + end end diff --git a/app/serializers/suggested_topic_serializer.rb b/app/serializers/suggested_topic_serializer.rb index 3d4b8f380fd..a0817917f5b 100644 --- a/app/serializers/suggested_topic_serializer.rb +++ b/app/serializers/suggested_topic_serializer.rb @@ -7,7 +7,7 @@ class SuggestedTopicSerializer < ListableTopicSerializer has_one :user, serializer: BasicUserSerializer, embed: :objects end - attributes :archetype, :like_count, :views, :category_id, :tags + attributes :archetype, :like_count, :views, :category_id, :tags, :featured_link has_many :posters, serializer: SuggestedPosterSerializer, embed: :objects def posters @@ -21,4 +21,12 @@ class SuggestedTopicSerializer < ListableTopicSerializer def tags object.tags.map(&:name) end + + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.featured_link + end end diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index a04c1eeae3c..9e1e642a159 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -10,7 +10,8 @@ class TopicListItemSerializer < ListableTopicSerializer :pinned_globally, :bookmarked_post_numbers, :liked_post_numbers, - :tags + :tags, + :featured_link has_many :posters, serializer: TopicPosterSerializer, embed: :objects has_many :participants, serializer: TopicPosterSerializer, embed: :objects @@ -72,4 +73,12 @@ class TopicListItemSerializer < ListableTopicSerializer object.tags.map(&:name) end + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.featured_link + end + end diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index e9e308b5dfc..780d2b80c37 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -56,7 +56,8 @@ class TopicViewSerializer < ApplicationSerializer :chunk_size, :bookmarked, :message_archived, - :tags + :tags, + :featured_link # TODO: Split off into proper object / serializer def details @@ -243,8 +244,17 @@ class TopicViewSerializer < ApplicationSerializer def include_tags? SiteSetting.tagging_enabled end + def tags object.topic.tags.map(&:name) end + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.topic.featured_link + end + end diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index 28036ce8ad7..d551dfe2d29 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -117,6 +117,9 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%= t.title -%> + <%- if SiteSetting.show_topic_featured_link_in_digest && t.featured_link %> + <%= raw topic_featured_link_domain(t.featured_link) %> + <%- end %> @@ -328,6 +331,9 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%= t.title -%> + <%- if SiteSetting.show_topic_featured_link_in_digest && t.featured_link %> + <%= raw topic_featured_link_domain(t.featured_link) %> + <%- end %> <%= category_badge(t.category, inline_style: true, absolute_url: true) %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 9cb484ec7d1..60c676becfd 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1074,6 +1074,7 @@ en: title_placeholder: "What is this discussion about in one brief sentence?" edit_reason_placeholder: "why are you editing?" show_edit_reason: "(add edit reason)" + topic_featured_link_placeholder: "Enter link shown with title." reply_placeholder: "Type here. Use Markdown, BBCode, or HTML to format. Drag or paste images." view_new_post: "View your new post." saving: "Saving" @@ -1875,6 +1876,7 @@ en: tags_allowed_tag_groups: "Tag groups that can only be used in this category:" tags_placeholder: "(Optional) list of allowed tags" tag_groups_placeholder: "(Optional) list of allowed tag groups" + topic_featured_link_allowed: "Restricts editing the topic featured link in this category. Require site setting topic_featured_link_enabled is checked." delete: 'Delete Category' create: 'New Category' create_long: 'Create a new category' diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a9048c3a738..3c1d84d8338 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -320,6 +320,7 @@ en: name: "Category Name" topic: title: 'Title' + featured_link: 'Featured Link' post: raw: "Body" user_profile: @@ -336,6 +337,9 @@ en: too_many_users: "You can only send warnings to one user at a time." cant_send_pm: "Sorry, you cannot send a private message to that user." no_user_selected: "You must select a valid user." + featured_link: + invalid: "is invalid. URL should include http:// or https://." + invalid_category: "can't be edited in this category." user: attributes: password: @@ -846,6 +850,10 @@ en: min_first_post_length: "Minimum allowed first post (topic body) length in characters" min_private_message_post_length: "Minimum allowed post length in characters for messages" max_post_length: "Maximum allowed post length in characters" + topic_featured_link_enabled: "Enable posting a link with topics." + topic_featured_link_onebox: "Show an onebox in the post body if possible and prevent editing post content." + open_topic_featured_link_in_external_window: "Open topic featured link in a external window." + show_topic_featured_link_in_digest: "Show the topic featured link in the digest email." min_topic_title_length: "Minimum allowed topic title length in characters" max_topic_title_length: "Maximum allowed topic title length in characters" min_private_message_title_length: "Minimum allowed title length for a message in characters" diff --git a/config/site_settings.yml b/config/site_settings.yml index 5899f29c66c..aae34135a64 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -433,6 +433,15 @@ posting: max_post_length: client: true default: 32000 + topic_featured_link_enabled: + client: true + default: false + topic_featured_link_onebox: + client: true + default: false + open_topic_featured_link_in_external_window: + client: true + default: true body_min_entropy: 7 min_topic_title_length: client: true @@ -596,6 +605,7 @@ email: disable_digest_emails: default: false client: true + show_topic_featured_link_in_digest: true email_custom_headers: 'Auto-Submitted: auto-generated' email_subject: '[%{site_name}] %{optional_pm}%{optional_cat}%{topic_title}' reply_by_email_enabled: diff --git a/lib/discourse_featured_link.rb b/lib/discourse_featured_link.rb new file mode 100644 index 00000000000..304383e9234 --- /dev/null +++ b/lib/discourse_featured_link.rb @@ -0,0 +1,27 @@ +module DiscourseFeaturedLink + CUSTOM_FIELD_NAME = 'featured_link'.freeze + + AdminDashboardData::GLOBAL_REPORTS << CUSTOM_FIELD_NAME + + Report.add_report(CUSTOM_FIELD_NAME) do |report| + report.data = [] + link_topics = TopicCustomField.where(name: CUSTOM_FIELD_NAME) + link_topics = link_topics.joins(:topic).where("topics.category_id = ?", report.category_id) if report.category_id + link_topics.where("topic_custom_fields.created_at >= ?", report.start_date) + .where("topic_custom_fields.created_at <= ?", report.end_date) + .group("DATE(topic_custom_fields.created_at)") + .order("DATE(topic_custom_fields.created_at)") + .count + .each { |date, count| report.data << { x: date, y: count } } + report.total = link_topics.count + report.prev30Days = link_topics.where("topic_custom_fields.created_at >= ?", report.start_date - 30.days) + .where("topic_custom_fields.created_at <= ?", report.start_date) + .count + end + + def self.cache_onebox_link(link) + # If the link is pasted swiftly, onebox may not have time to cache it + Oneboxer.onebox(link, invalidate_oneboxes: false) + link + end +end diff --git a/lib/email/styles.rb b/lib/email/styles.rb index db32a221d7a..39adcf88cd1 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -67,6 +67,11 @@ module Email add_styles(img, 'max-width: 100%;') if img['style'] !~ /max-width/ end + # topic featured link + @fragment.css('a.topic-featured-link').each do |e| + e['style'] = "color:#858585;padding:2px 8px;border:1px solid #e6e6e6;border-radius:2px;box-shadow:0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);" + end + # attachments @fragment.css('a.attachment').each do |a| # ensure all urls are absolute diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index dbe5f868194..ccebde7dbf1 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -68,4 +68,9 @@ module CategoryGuardian def topic_create_allowed_category_ids @topic_create_allowed_category_ids ||= @user.topic_create_allowed_category_ids end + + def topic_featured_link_allowed_category_ids + @topic_featured_link_allowed_category_ids = CategoryCustomField.where(name: "topic_featured_link_allowed", value: "true") + .pluck(:category_id) + end end diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 892a8517d9a..b107fc7e669 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -105,4 +105,9 @@ module TopicGuardian records end + def can_edit_featured_link?(category_id) + SiteSetting.topic_featured_link_enabled && + (topic_featured_link_allowed_category_ids.empty? || # no per category restrictions + category_id && topic_featured_link_allowed_category_ids.include?(category_id.to_i)) # category restriction exists + end end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 9b4fb345dd8..6a788cf5bdb 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -5,6 +5,7 @@ require_dependency 'topic_creator' require_dependency 'post_jobs_enqueuer' require_dependency 'distributed_mutex' require_dependency 'has_errors' +require_dependency 'discourse_featured_link' class PostCreator include HasErrors @@ -103,6 +104,11 @@ class PostCreator end end + onebox_featured_link = SiteSetting.topic_featured_link_enabled && SiteSetting.topic_featured_link_onebox && guardian.can_edit_featured_link?(find_category_id) + if onebox_featured_link + @opts[:raw] = DiscourseFeaturedLink.cache_onebox_link(@opts[:featured_link]) + end + setup_post return true if skip_validations? @@ -116,7 +122,7 @@ class PostCreator DiscourseEvent.trigger :before_create_post, @post DiscourseEvent.trigger :validate_post, @post - post_validator = Validators::PostValidator.new(skip_topic: true) + post_validator = Validators::PostValidator.new(skip_topic: true, skip_post_body: onebox_featured_link) post_validator.validate(@post) valid = @post.errors.blank? @@ -338,6 +344,18 @@ class PostCreator private + # TODO: merge the similar function in TopicCreator and fix parameter naming for `category` + def find_category_id + @opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message + + category = if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/) + Category.find_by(id: @opts[:category]) + else + Category.find_by(name_lower: @opts[:category].try(:downcase)) + end + category&.id + end + def create_topic return if @topic begin diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index f253d3712e3..55d061ef91d 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -95,6 +95,23 @@ class PostRevisor end end + track_topic_field(:featured_link) do |topic_changes, featured_link| + if SiteSetting.topic_featured_link_enabled && + featured_link.present? && + topic_changes.guardian.can_edit_featured_link?(topic_changes.topic.category_id) + + topic_changes.record_change('featured_link', topic_changes.topic.featured_link, featured_link) + topic_changes.topic.featured_link = featured_link + + if SiteSetting.topic_featured_link_onebox + post = topic_changes.topic.first_post + post.raw = DiscourseFeaturedLink.cache_onebox_link(featured_link) + post.save! + post.rebake! + end + end + end + # AVAILABLE OPTIONS: # - revised_at: changes the date of the revision # - force_new_version: bypass ninja-edit window diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index 9e76327a5a8..8c0621d507c 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -124,6 +124,10 @@ class TopicCreator topic_params[:pinned_at] = Time.zone.parse(@opts[:pinned_at].to_s) if @opts[:pinned_at].present? topic_params[:pinned_globally] = @opts[:pinned_globally] if @opts[:pinned_globally].present? + if SiteSetting.topic_featured_link_enabled && @opts[:featured_link].present? && @guardian.can_edit_featured_link?(topic_params[:category_id]) + topic_params[:featured_link] = @opts[:featured_link] + end + topic_params end diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb index 32f6063596c..30e68bafad7 100644 --- a/lib/validators/post_validator.rb +++ b/lib/validators/post_validator.rb @@ -10,8 +10,7 @@ class Validators::PostValidator < ActiveModel::Validator return if record.acting_user.try(:staged?) return if record.acting_user.try(:admin?) && Discourse.static_doc_topic_ids.include?(record.topic_id) - stripped_length(record) - raw_quality(record) + post_body_validator(record) max_posts_validator(record) max_mention_validator(record) max_images_validator(record) @@ -21,8 +20,6 @@ class Validators::PostValidator < ActiveModel::Validator end def presence(post) - post.errors.add(:raw, :blank, options) if post.raw.blank? - unless options[:skip_topic] post.errors.add(:topic_id, :blank, options) if post.topic_id.blank? end @@ -32,6 +29,12 @@ class Validators::PostValidator < ActiveModel::Validator end end + def post_body_validator(post) + return if options[:skip_post_body] + stripped_length(post) + raw_quality(post) + end + def stripped_length(post) range = if private_message?(post) # private message diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index c5e637f189a..bf51aadfe4d 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -2280,4 +2280,27 @@ describe Guardian do end end end + + context 'topic featured link category restriction' do + before { SiteSetting.topic_featured_link_enabled = true } + let(:guardian) { Guardian.new } + + it 'returns true if no category restricts editing link' do + expect(guardian.can_edit_featured_link?(nil)).to eq(true) + expect(guardian.can_edit_featured_link?(5)).to eq(true) + end + + context 'when exist' do + let!(:category) { Fabricate(:category) } + let!(:link_category) { Fabricate(:link_category) } + + it 'returns true if the category is listed' do + expect(guardian.can_edit_featured_link?(link_category.id)).to eq(true) + end + + it 'returns false if the category is not listed' do + expect(guardian.can_edit_featured_link?(category.id)).to eq(false) + end + end + end end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index a428bb03b81..197e6be9d40 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -20,6 +20,7 @@ describe PostCreator do let(:creator_with_category) { PostCreator.new(user, basic_topic_params.merge(category: category.id )) } let(:creator_with_meta_data) { PostCreator.new(user, basic_topic_params.merge(meta_data: {hello: "world"} )) } let(:creator_with_image_sizes) { PostCreator.new(user, basic_topic_params.merge(image_sizes: image_sizes)) } + let(:creator_with_featured_link) { PostCreator.new(user, title: "featured link topic", archetype_id: 1, featured_link: "http://discourse.org") } it "can create a topic with null byte central" do post = PostCreator.create(user, title: "hello\u0000world this is title", raw: "this is my\u0000 first topic") @@ -243,6 +244,14 @@ describe PostCreator do end end + it 'creates a post without raw' do + SiteSetting.topic_featured_link_enabled = true + SiteSetting.topic_featured_link_onebox = true + post = creator_with_featured_link.create + expect(post.topic.featured_link).to eq('http://discourse.org') + expect(post.raw).to eq('http://discourse.org') + end + describe "topic's auto close" do it "doesn't update topic's auto close when it's not based on last post" do diff --git a/spec/components/validators/post_validator_spec.rb b/spec/components/validators/post_validator_spec.rb index b497d4454c7..093cff9ff52 100644 --- a/spec/components/validators/post_validator_spec.rb +++ b/spec/components/validators/post_validator_spec.rb @@ -5,6 +5,16 @@ describe Validators::PostValidator do let(:post) { build(:post) } let(:validator) { Validators::PostValidator.new({}) } + context "when empty raw can bypass post body validation" do + let(:validator) { Validators::PostValidator.new(skip_post_body: true) } + + it "should be allowed for empty raw based on site setting" do + post.raw = "" + validator.post_body_validator(post) + expect(post.errors).to be_empty + end + end + context "stripped_length" do it "adds an error for short raw" do post.raw = "abc" diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 99af04c81ab..9f29a456393 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -579,10 +579,6 @@ describe PostsController do let(:moderator) { log_in(:moderator) } let(:new_post) { Fabricate.build(:post, user: user) } - it "raises an exception without a raw parameter" do - expect { xhr :post, :create }.to raise_error(ActionController::ParameterMissing) - end - context "fast typing" do before do SiteSetting.min_first_post_typing_time = 3000 @@ -771,8 +767,8 @@ describe PostsController do end it "passes category through" do - xhr :post, :create, {raw: 'hello', category: 'cool'} - expect(assigns(:manager_params)['category']).to eq('cool') + xhr :post, :create, {raw: 'hello', category: 1} + expect(assigns(:manager_params)['category']).to eq('1') end it "passes target_usernames through" do diff --git a/spec/fabricators/embeddable_host_fabricator.rb b/spec/fabricators/embeddable_host_fabricator.rb index a37f5b4c8ba..9f589d389e0 100644 --- a/spec/fabricators/embeddable_host_fabricator.rb +++ b/spec/fabricators/embeddable_host_fabricator.rb @@ -25,3 +25,7 @@ Fabricator(:private_category, from: :category) do cat.category_groups.build(group_id: transients[:group].id, permission_type: CategoryGroup.permission_types[:full]) end end + +Fabricator(:link_category, from: :category) do + before_validation { |category, transients| category.custom_fields['topic_featured_link_allowed'] = 'true' } +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index d603077ebf9..efe28cb6be7 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -421,14 +421,14 @@ describe Category do describe 'latest' do it 'should be updated correctly' do category = Fabricate(:category) - post = create_post(category: category.name) + post = create_post(category: category.id) category.reload expect(category.latest_post_id).to eq(post.id) expect(category.latest_topic_id).to eq(post.topic_id) - post2 = create_post(category: category.name) - post3 = create_post(topic_id: post.topic_id, category: category.name) + post2 = create_post(category: category.id) + post3 = create_post(topic_id: post.topic_id, category: category.id) category.reload expect(category.latest_post_id).to eq(post3.id) @@ -451,7 +451,7 @@ describe Category do context 'with regular topics' do before do - create_post(user: @category.user, category: @category.name) + create_post(user: @category.user, category: @category.id) Category.update_stats @category.reload end @@ -491,7 +491,7 @@ describe Category do context 'with revised post' do before do - post = create_post(user: @category.user, category: @category.name) + post = create_post(user: @category.user, category: @category.id) SiteSetting.stubs(:editing_grace_period).returns(1.minute.to_i) post.revise(post.user, { raw: 'updated body' }, revised_at: post.updated_at + 2.minutes) diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index f2ac723e6ec..0f35c714483 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1725,7 +1725,6 @@ describe Topic do expect(@topic_status_event_triggered).to eq(true) end - it 'allows users to normalize counts' do topic = Fabricate(:topic, last_posted_at: 1.year.ago) @@ -1741,4 +1740,39 @@ describe Topic do expect(topic.last_posted_at).to be_within(1.second).of (post1.created_at) end + context 'featured link' do + before { SiteSetting.topic_featured_link_enabled = true } + let(:topic) { Fabricate(:topic) } + + it 'can validate featured link' do + topic.featured_link = ' invalid string' + + expect(topic).not_to be_valid + expect(topic.errors[:featured_link]).to be_present + end + + it 'can properly save the featured link' do + topic.featured_link = ' https://github.com/discourse/discourse' + + expect(topic.save).to be_truthy + expect(topic.custom_fields['featured_link']).to eq('https://github.com/discourse/discourse') + end + + context 'when category restricts present' do + let!(:link_category) { Fabricate(:link_category) } + let(:topic) { Fabricate(:topic) } + let(:link_topic) { Fabricate(:topic, category: link_category) } + + it 'can save the featured link if it belongs to that category' do + link_topic.featured_link = 'https://github.com/discourse/discourse' + expect(link_topic.save).to be_truthy + expect(link_topic.custom_fields['featured_link']).to eq('https://github.com/discourse/discourse') + end + + it 'can not save the featured link if it belongs to that category' do + topic.featured_link = 'https://github.com/discourse/discourse' + expect(topic.save).to be_falsey + end + end + end end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 39457073a37..36863e798d7 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -28,7 +28,7 @@ module Helpers args[:title] ||= "This is my title #{Helpers.next_seq}" user = args.delete(:user) || Fabricate(:user) guardian = Guardian.new(user) - args[:category] = args[:category].name if args[:category].is_a?(Category) + args[:category] = args[:category].id if args[:category].is_a?(Category) TopicCreator.create(user, guardian, args) end @@ -37,7 +37,7 @@ module Helpers args[:raw] ||= "This is the raw body of my post, it is cool #{Helpers.next_seq}" args[:topic_id] = args[:topic].id if args[:topic] user = args.delete(:user) || Fabricate(:user) - args[:category] = args[:category].name if args[:category].is_a?(Category) + args[:category] = args[:category].id if args[:category].is_a?(Category) creator = PostCreator.new(user, args) post = creator.create diff --git a/test/javascripts/models/composer-test.js.es6 b/test/javascripts/models/composer-test.js.es6 index d541aaeb087..1ed5a691c37 100644 --- a/test/javascripts/models/composer-test.js.es6 +++ b/test/javascripts/models/composer-test.js.es6 @@ -40,6 +40,10 @@ test('missingReplyCharacters', function() { missingReplyCharacters('hi', false, false, Discourse.SiteSettings.min_post_length - 2, 'too short public post'); missingReplyCharacters('hi', false, true, Discourse.SiteSettings.min_first_post_length - 2, 'too short first post'); missingReplyCharacters('hi', true, false, Discourse.SiteSettings.min_private_message_post_length - 2, 'too short private message'); + + Discourse.SiteSettings.topic_featured_link_onebox = true; + const composer = createComposer({ canEditTopicFeaturedLink: true }); + equal(composer.get('missingReplyCharacters'), 0, "don't require any post content"); }); test('missingTitleCharacters', function() { @@ -105,7 +109,7 @@ test("prependText", function() { composer.prependText("world "); equal(composer.get('reply'), "world hello", "it prepends text to existing text"); - + composer.prependText("before new line", {new_line: true}); equal(composer.get('reply'), "before new line\n\nworld hello", "it prepends text with new line to existing text"); });
<%= category_badge(t.category, inline_style: true, absolute_url: true) %>