diff --git a/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 b/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 index ca6d0386a..19103360d 100644 --- a/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 +++ b/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 @@ -1,8 +1,19 @@ export default Ember.Component.extend({ classNameBindings: ['containerClass'], - layoutName: 'components/conditional-loading-spinner', containerClass: function() { return (this.get('size') === 'small') ? 'inline-spinner' : undefined; - }.property('size') + }.property('size'), + + render: function(buffer) { + if (this.get('condition')) { + buffer.push('
'); + } else { + return this._super(); + } + }, + + _conditionChanged: function() { + this.rerender(); + }.observes('condition') }); diff --git a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 index 7f0f66549..443d3bcb7 100644 --- a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 @@ -146,9 +146,9 @@ export default ObjectController.extend(ModalFunctionality, { }).catch(function(error) { if (error && error.responseText) { - self.flash($.parseJSON(error.responseText).errors[0]); + self.flash($.parseJSON(error.responseText).errors[0], 'error'); } else { - self.flash(I18n.t('generic_error')); + self.flash(I18n.t('generic_error'), 'error'); } self.set('saving', false); }); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 92e06d8f8..db94ea85b 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -1,7 +1,8 @@ import ObjectController from 'discourse/controllers/object'; +import BufferedContent from 'discourse/mixins/buffered-content'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; -export default ObjectController.extend(Discourse.SelectedPostsCount, { +export default ObjectController.extend(Discourse.SelectedPostsCount, BufferedContent, { multiSelect: false, needs: ['header', 'modal', 'composer', 'quote-button', 'search', 'topic-progress', 'application'], allPostsSelected: false, @@ -235,11 +236,6 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, { this.set('allPostsSelected', false); }, - /** - Toggle a participant for filtering - - @method toggleParticipant - **/ toggleParticipant: function(user) { this.get('postStream').toggleParticipant(Em.get(user, 'username')); }, @@ -247,17 +243,13 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, { editTopic: function() { if (!this.get('details.can_edit')) return false; - this.setProperties({ - editingTopic: true, - newTitle: this.get('title'), - newCategoryId: this.get('category_id') - }); + this.set('editingTopic', true); return false; }, - // close editing mode cancelEditingTopic: function() { this.set('editingTopic', false); + this.rollbackBuffer(); }, toggleMultiSelect: function() { @@ -265,39 +257,24 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, { }, finishedEditingTopic: function() { - if (this.get('editingTopic')) { + if (!this.get('editingTopic')) { return; } - var topic = this.get('model'); + // save the modifications + var self = this, + props = this.get('buffered.buffer'); - // Topic title hasn't been sanitized yet, so the template shouldn't trust it. - this.set('topicSaving', true); - - // manually update the titles & category - var backup = topic.setPropertiesBackup({ - title: this.get('newTitle'), - category_id: parseInt(this.get('newCategoryId'), 10), - fancy_title: this.get('newTitle') - }); - - // save the modifications - var self = this; - topic.save().then(function(result){ - // update the title if it has been changed (cleaned up) server-side - topic.setProperties(Em.getProperties(result.basic_topic, 'title', 'fancy_title')); - self.set('topicSaving', false); - }, function(error) { - self.setProperties({ editingTopic: true, topicSaving: false }); - topic.setProperties(backup); - if (error && error.responseText) { - bootbox.alert($.parseJSON(error.responseText).errors[0]); - } else { - bootbox.alert(I18n.t('generic_error')); - } - }); - - // close editing mode + Discourse.Topic.update(this.get('model'), props).then(function() { + // Note we roll back on success here because `update` saves + // the properties to the topic. + self.rollbackBuffer(); self.set('editingTopic', false); - } + }).catch(function(error) { + if (error && error.responseText) { + bootbox.alert($.parseJSON(error.responseText).errors[0]); + } else { + bootbox.alert(I18n.t('generic_error')); + } + }); }, toggledSelectedPost: function(post) { diff --git a/app/assets/javascripts/discourse/helpers/link-domain.js.es6 b/app/assets/javascripts/discourse/helpers/link-domain.js.es6 index b16255d94..ba59b7f50 100644 --- a/app/assets/javascripts/discourse/helpers/link-domain.js.es6 +++ b/app/assets/javascripts/discourse/helpers/link-domain.js.es6 @@ -1,5 +1,6 @@ -Handlebars.registerHelper('link-domain', function(property, options) { - var link = Em.get(this, property, options); +import registerUnbound from 'discourse/helpers/register-unbound'; + +registerUnbound('link-domain', function(link) { if (link) { var internal = Em.get(link, 'internal'), hasTitle = (!Em.isEmpty(Em.get(link, 'title'))); diff --git a/app/assets/javascripts/discourse/lib/emoji/emoji-autocomplete.js b/app/assets/javascripts/discourse/lib/emoji/emoji-autocomplete.js deleted file mode 100644 index 48abf219f..000000000 --- a/app/assets/javascripts/discourse/lib/emoji/emoji-autocomplete.js +++ /dev/null @@ -1,46 +0,0 @@ -// TODO: Make this a proper ES6 import -var ComposerView = require('discourse/views/composer').default; - -ComposerView.on("initWmdEditor", function(){ - if (!Discourse.SiteSettings.enable_emoji) { return; } - - var template = Handlebars.compile( - "
" + - "" + - "
" - ); - - $('#wmd-input').autocomplete({ - template: template, - key: ":", - transformComplete: function(v){ return v.code + ":"; }, - dataSource: function(term){ - return new Ember.RSVP.Promise(function(resolve) { - var full = ":" + term; - term = term.toLowerCase(); - - if (term === "") { - return resolve(["smile", "smiley", "wink", "sunny", "blush"]); - } - - if (Discourse.Emoji.translations[full]) { - return resolve([Discourse.Emoji.translations[full]]); - } - - var options = Discourse.Emoji.search(term, {maxResults: 5}); - - return resolve(options); - }).then(function(list) { - return list.map(function(i) { - return {code: i, src: Discourse.Emoji.urlFor(i)}; - }); - }); - } - }); -}); diff --git a/app/assets/javascripts/discourse/models/category.js b/app/assets/javascripts/discourse/models/category.js index 3f0732328..3f8bb6edb 100644 --- a/app/assets/javascripts/discourse/models/category.js +++ b/app/assets/javascripts/discourse/models/category.js @@ -58,6 +58,7 @@ Discourse.Category = Discourse.Model.extend({ return Discourse.ajax(url, { data: { name: this.get('name'), + slug: this.get('slug'), color: this.get('color'), text_color: this.get('text_color'), secure: this.get('secure'), diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 2d9523cac..6b166a8ab 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -463,13 +463,10 @@ Discourse.Composer = Discourse.Model.extend({ // Update the title if we've changed it if (this.get('title') && post.get('post_number') === 1) { - var topic = this.get('topic'); - topic.setProperties({ + Discourse.Topic.update(this.get('topic'), { title: this.get('title'), - fancy_title: Handlebars.Utils.escapeExpression(this.get('title')), - category_id: parseInt(this.get('categoryId'), 10) + category_id: this.get('categoryId') }); - topic.save(); } post.setProperties({ diff --git a/app/assets/javascripts/discourse/models/export_csv.js b/app/assets/javascripts/discourse/models/export_csv.js index be678c392..9d5c00d89 100644 --- a/app/assets/javascripts/discourse/models/export_csv.js +++ b/app/assets/javascripts/discourse/models/export_csv.js @@ -30,7 +30,7 @@ Discourse.ExportCsv.reopenClass({ @method export_user_list **/ exportUserList: function() { - return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user_list'}}); }, /** diff --git a/app/assets/javascripts/discourse/models/model.js b/app/assets/javascripts/discourse/models/model.js index 084c7c72a..0c2619c7c 100644 --- a/app/assets/javascripts/discourse/models/model.js +++ b/app/assets/javascripts/discourse/models/model.js @@ -1,12 +1,4 @@ -Discourse.Model = Ember.Object.extend(Discourse.Presence, { - // Like `setProperties` but returns the original values in case - // we want to roll back - setPropertiesBackup: function(obj) { - var backup = this.getProperties(Ember.keys(obj)); - this.setProperties(obj); - return backup; - } -}); +Discourse.Model = Ember.Object.extend(Discourse.Presence); Discourse.Model.reopenClass({ extractByKey: function(collection, klass) { diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js index 817376fbf..6df0bbff5 100644 --- a/app/assets/javascripts/discourse/models/topic.js +++ b/app/assets/javascripts/discourse/models/topic.js @@ -202,23 +202,6 @@ Discourse.Topic = Discourse.Model.extend({ }); }, - // Save any changes we've made to the model - save: function() { - // Don't save unless we can - if (!this.get('details.can_edit')) return; - - var data = { title: this.get('title') }; - - if(this.get('category')){ - data.category_id = this.get('category.id'); - } - - return Discourse.ajax(this.get('url'), { - type: 'PUT', - data: data - }); - }, - /** Invite a user to this topic @@ -373,6 +356,29 @@ Discourse.Topic.reopenClass({ } }, + update: function(topic, props) { + props = JSON.parse(JSON.stringify(props)) || {}; + + // Annoyingly, empty arrays are not sent across the wire. This + // allows us to make a distinction between arrays that were not + // sent and arrays that we specifically want to be empty. + Object.keys(props).forEach(function(k) { + var v = props[k]; + if (v instanceof Array && v.length === 0) { + props[k + '_empty_array'] = true; + } + }); + + return Discourse.ajax(topic.get('url'), { type: 'PUT', data: props }).then(function(result) { + + // The title can be cleaned up server side + props.title = result.basic_topic.title; + props.fancy_title = result.basic_topic.fancy_title; + + topic.setProperties(props); + }); + }, + create: function() { var result = this._super.apply(this, arguments); this.createActionSummary(result); diff --git a/app/assets/javascripts/discourse/templates/components/conditional-loading-spinner.hbs b/app/assets/javascripts/discourse/templates/components/conditional-loading-spinner.hbs deleted file mode 100644 index d1d27ae58..000000000 --- a/app/assets/javascripts/discourse/templates/components/conditional-loading-spinner.hbs +++ /dev/null @@ -1,5 +0,0 @@ -{{#if condition}} -
-{{else}} - {{yield}} -{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/topic-map.hbs b/app/assets/javascripts/discourse/templates/components/topic-map.hbs index 3e3a73c26..bf572caa3 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-map.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-map.hbs @@ -82,7 +82,7 @@ {{#if showAllLinksControls}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/emoji-selector-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/emoji-selector-autocomplete.raw.hbs new file mode 100644 index 000000000..ed2e39c7a --- /dev/null +++ b/app/assets/javascripts/discourse/templates/emoji-selector-autocomplete.raw.hbs @@ -0,0 +1,9 @@ +
+ +
diff --git a/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs b/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs index 096441c01..7931c5ef5 100644 --- a/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs +++ b/app/assets/javascripts/discourse/templates/modal/edit-category-general.hbs @@ -1,7 +1,13 @@
- - {{text-field value=name placeholderKey="category.name_placeholder" maxlength="50"}} +
+ + {{text-field value=name placeholderKey="category.name_placeholder" maxlength="50"}} +
+
+ + {{text-field value=slug placeholderKey="category.slug_placeholder" maxlength="255"}} +
{{#if canSelectParentCategory}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index f724e92ff..3deaa666d 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -16,11 +16,11 @@ {{#if editingTopic}} {{#if isPrivateMessage}} {{fa-icon envelope}} - {{autofocus-text-field id='edit-title' value=newTitle maxLength=maxTitleLength}} + {{autofocus-text-field id='edit-title' value=buffered.title maxLength=maxTitleLength}} {{else}} - {{autofocus-text-field id='edit-title' value=newTitle maxLength=maxTitleLength}} + {{autofocus-text-field id='edit-title' value=buffered.title maxLength=maxTitleLength}}
- {{category-chooser valueAttribute="id" value=newCategoryId source=category_id}} + {{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}} {{/if}} @@ -34,11 +34,7 @@ {{#if details.loaded}} {{topic-status topic=model}} - {{#if topicSaving}} - {{fancy_title}} - {{else}} - {{{fancy_title}}} - {{/if}} + {{{fancy_title}}} {{/if}} diff --git a/app/assets/javascripts/discourse/views/category-chooser.js.es6 b/app/assets/javascripts/discourse/views/category-chooser.js.es6 index b0bda8b33..46a82d012 100644 --- a/app/assets/javascripts/discourse/views/category-chooser.js.es6 +++ b/app/assets/javascripts/discourse/views/category-chooser.js.es6 @@ -7,6 +7,7 @@ export default ComboboxView.extend({ overrideWidths: true, dataAttributes: ['id', 'description_text'], valueBinding: Ember.Binding.oneWay('source'), + castInteger: true, content: function() { var scopedCategoryId = this.get('scopedCategoryId'); diff --git a/app/assets/javascripts/discourse/views/combo-box.js.es6 b/app/assets/javascripts/discourse/views/combo-box.js.es6 index 24d4b2d28..186683673 100644 --- a/app/assets/javascripts/discourse/views/combo-box.js.es6 +++ b/app/assets/javascripts/discourse/views/combo-box.js.es6 @@ -63,7 +63,7 @@ export default Discourse.View.extend({ this.rerender(); }.observes('content.@each'), - didInsertElement: function() { + _initializeCombo: function() { var $elem = this.$(), self = this; @@ -75,10 +75,15 @@ export default Discourse.View.extend({ $elem.select2({formatResult: this.template, minimumResultsForSearch: 5, width: 'resolve'}); + var castInteger = this.get('castInteger'); $elem.on("change", function (e) { - self.set('value', $(e.target).val()); + var val = $(e.target).val(); + if (val.length && castInteger) { + val = parseInt(val, 10); + } + self.set('value', val); }); - }, + }.on('didInsertElement'), willClearRender: function() { var elementId = "s2id_" + this.$().attr('id'); diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index f69462c71..325e23f13 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -163,6 +163,39 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { this.trigger('previewRefreshed', $wmdPreview); }, + _applyEmojiAutocomplete: function() { + if (!this.siteSettings.enable_emoji) { return; } + + var template = this.container.lookup('template:emoji-selector-autocomplete.raw'); + $('#wmd-input').autocomplete({ + template: template, + key: ":", + transformComplete: function(v){ return v.code + ":"; }, + dataSource: function(term){ + return new Ember.RSVP.Promise(function(resolve) { + var full = ":" + term; + term = term.toLowerCase(); + + if (term === "") { + return resolve(["smile", "smiley", "wink", "sunny", "blush"]); + } + + if (Discourse.Emoji.translations[full]) { + return resolve([Discourse.Emoji.translations[full]]); + } + + var options = Discourse.Emoji.search(term, {maxResults: 5}); + + return resolve(options); + }).then(function(list) { + return list.map(function(i) { + return {code: i, src: Discourse.Emoji.urlFor(i)}; + }); + }); + } + }); + }, + initEditor: function() { // not quite right, need a callback to pass in, meaning this gets called once, // but if you start replying to another topic it will get the avatars wrong @@ -172,6 +205,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { $LAB.script(assetPath('defer/html-sanitizer-bundle')); ComposerView.trigger("initWmdEditor"); + this._applyEmojiAutocomplete(); var template = this.container.lookup('template:user-selector-autocomplete.raw'); $wmdInput.data('init', true); diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 8e35ab13b..bc7728447 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -130,6 +130,10 @@ section.field { margin-bottom: 20px; } + section.field .field-item { + display: inline-block; + margin-right: 10px; + } } .reply-where-modal { diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8197b89a4..e62999fe0 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -320,12 +320,15 @@ class Admin::UsersController < Admin::AdminController user.email_tokens.update_all confirmed: true email_token = user.email_tokens.create(email: user.email) - Jobs.enqueue(:user_email, + + unless params[:send_email] == '0' || params[:send_email] == 'false' + Jobs.enqueue( :user_email, type: :account_created, user_id: user.id, email_token: email_token.token) + end - render json: success_json + render json: success_json.merge!(password_url: "#{Discourse.base_url}/users/password-reset/#{email_token.token}") end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 3fb80f71a..a97f7ff77 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -95,6 +95,19 @@ class CategoriesController < ApplicationController end end + def update_slug + @category = Category.find(params[:category_id].to_i) + guardian.ensure_can_edit!(@category) + + custom_slug = params[:slug].to_s + + if custom_slug.present? && @category.update_attributes(slug: custom_slug) + render json: success_json + else + render_json_error(@category) + end + end + def set_notifications category_id = params[:category_id].to_i notification_level = params[:notification_level].to_i diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb index c28ffe864..a8f875ede 100644 --- a/app/controllers/export_csv_controller.rb +++ b/app/controllers/export_csv_controller.rb @@ -15,7 +15,7 @@ class ExportCsvController < ApplicationController def show params.require(:id) filename = params.fetch(:id) - export_id = filename.split('_')[1].split('.')[0] + export_id = filename.split('-')[2].split('.')[0] export_initiated_by_user_id = 0 export_initiated_by_user_id = UserExport.where(id: export_id)[0].user_id unless UserExport.where(id: export_id).empty? export_csv_path = UserExport.get_download_path(filename) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 63b45f734..73bc006ae 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -2,6 +2,7 @@ require_dependency 'topic_view' require_dependency 'promotion' require_dependency 'url_helper' require_dependency 'topics_bulk_action' +require_dependency 'discourse_event' class TopicsController < ApplicationController include UrlHelper @@ -134,6 +135,8 @@ class TopicsController < ApplicationController success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false) end + DiscourseEvent.trigger(:topic_saved, topic, params) + # this is used to return the title to the client as it may have been changed by "TextCleaner" success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic) end diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index c0b86a7f9..b0ae17c36 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -24,6 +24,7 @@ module Jobs def execute(args) entity = args[:entity] + @file_name = entity if entity == "user_archive" @entity_type = "user" @@ -56,19 +57,25 @@ module Jobs end end - def user_export + def user_list_export query = ::AdminUserIndexQuery.new user_data = query.find_users_query.to_a user_data.map do |user| group_names = get_group_names(user).join(';') - user_array = get_user_fields(user) + user_array = get_user_list_fields(user) user_array.push(group_names) if group_names != '' user_array end end def staff_action_export - staff_action_data = UserHistory.order('id DESC').to_a + if @current_user.admin? + staff_action_data = UserHistory.only_staff_actions.order('id DESC').to_a + else + # moderator + staff_action_data = UserHistory.where(admin_only: false).only_staff_actions.order('id DESC').to_a + end + staff_action_data.map do |staff_action| get_staff_action_fields(staff_action) end @@ -162,7 +169,7 @@ module Jobs user_archive_array end - def get_user_fields(user) + def get_user_list_fields(user) user_array = [] HEADER_ATTRS_FOR['user'].each do |attr| @@ -265,7 +272,8 @@ module Jobs def set_file_path @file = UserExport.create(export_type: @entity_type, user_id: @current_user.id) - @file_name = "export_#{@file.id}.csv" + file_name_prefix = @file_name.split('_').join('-') + @file_name = "#{file_name_prefix}-#{@file.id}.csv" # ensure directory exists dir = File.dirname("#{UserExport.base_directory}/#{@file_name}") diff --git a/app/models/category.rb b/app/models/category.rb index 4103eb869..3535019b7 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -201,18 +201,19 @@ SQL end def ensure_slug - if name.present? - self.name.strip! + return unless name.present? - if slug.present? - # custom slug - errors.add(:slug, "is already in use") if duplicate_slug? - else - # auto slug - self.slug = Slug.for(name) - return if self.slug.blank? - self.slug = '' if duplicate_slug? - end + self.name.strip! + + if slug.present? + # santized custom slug + self.slug = Slug.for(slug) + errors.add(:slug, 'is already in use') if duplicate_slug? + else + # auto slug + self.slug = Slug.for(name) + return if self.slug.blank? + self.slug = '' if duplicate_slug? end end diff --git a/app/models/user_history.rb b/app/models/user_history.rb index 90aae0c0b..b4bfcad6a 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -20,7 +20,7 @@ class UserHistory < ActiveRecord::Base :change_site_setting, :change_site_customization, :delete_site_customization, - :checked_for_custom_avatar, + :checked_for_custom_avatar, # not used anymore :notified_about_avatar, :notified_about_sequential_replies, :notified_about_dominating_topic, diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 334aff879..367f11086 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -1,14 +1,13 @@ - - - + + - - + + - <%= canonical_link_tag %> +<%= canonical_link_tag %> - <%= render partial: "common/special_font_face" %> - <%= render partial: "common/discourse_stylesheet" %> +<%= render partial: "common/special_font_face" %> +<%= render partial: "common/discourse_stylesheet" %> - <%= discourse_csrf_tags %> +<%= discourse_csrf_tags %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 111e92e81..cc71444dd 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -7,11 +7,11 @@ <%= render partial: "layouts/head" %> <%- if SiteSetting.enable_escaped_fragments? %> - + <%- end %> <%- if shared_session_key %> - + <%- end %> <%= script "preload_store" %> diff --git a/app/views/list/list.erb b/app/views/list/list.erb index 4091f0b01..669b8f9eb 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -20,13 +20,13 @@ <% if @rss %> <% content_for :head do %> - <%= auto_discovery_link_tag(:rss, {action: "#{@rss}_feed"}, title: I18n.t("rss_description.#{@rss}")) %> + <%= auto_discovery_link_tag(:rss, { action: "#{@rss}_feed" }, title: I18n.t("rss_description.#{@rss}")) %> <% end %> <% end %> <% if @category %> <% content_for :head do %> - <%= auto_discovery_link_tag(@category, {action: :category_feed, format: :rss}, title: t('rss_topics_in_category', category: @category.name), type: 'application/rss+xml') %> + <%= auto_discovery_link_tag(:rss, { action: :category_feed }, title: t('rss_topics_in_category', category: @category.name)) %> <% end %> <% end %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 801fc0b37..0cd56c491 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1281,6 +1281,8 @@ en: delete: 'Delete Category' create: 'New Category' save: 'Save Category' + slug: 'Category Slug' + slug_placeholder: '(Optional) dashed-words for url' creation_error: There has been an error during the creation of the category. save_error: There was an error saving the category. name: "Category Name" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2c028863e..712239e16 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -598,14 +598,14 @@ en: s3_backup_config_warning: 'The server is configured to upload backups to s3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key or s3_backup_bucket. Go to the Site Settings and update the settings. See "How to set up image uploads to S3?" to learn more.' image_magick_warning: 'The server is configured to create thumbnails of large images, but ImageMagick is not installed. Install ImageMagick using your favorite package manager or download the latest release.' failing_emails_warning: 'There are %{num_failed_jobs} email jobs that failed. Check your config/discourse.conf file and ensure that the mail server settings are correct. See the failed jobs in Sidekiq.' - default_logo_warning: "You haven't customized the logo images for your site. Update logo_url, logo_small_url, and favicon_url in the Site Settings." - contact_email_missing: "You haven't provided a contact email for your site. Please update contact_email in the Site Settings." - contact_email_invalid: "The site contact email is invalid. Please update contact_email in the Site Settings." - title_nag: "The title Site Setting is still set to the default value. Please update it with your site's title in the Site Settings." - site_description_missing: "The site_description setting is blank. Write a brief description of this forum in the Site Settings." + default_logo_warning: "Set the graphic logos for your site. Update logo_url, logo_small_url, and favicon_url in Site Settings." + contact_email_missing: "Enter a site contact email address so new users or users who can't log in, as well as other webmasters and system administrators, can reach you for urgent matters. Update it in Site Settings." + contact_email_invalid: "The site contact email is invalid. Update it in Site Settings." + title_nag: "Enter the name of your site. Update title in Site Settings." + site_description_missing: "Enter a one sentence description of your site that will appear in search results. Update site_description in Site Settings." consumer_email_warning: "Your site is configured to use Gmail (or another consumer email service) to send email. Gmail limits how many emails you can send. Consider using an email service provider like mandrill.com to ensure email deliverability." access_password_removal: "Your site was using the access_password setting, which has been removed. The login_required and must_approve_users settings have been enabled, which should be used instead. You can change them in the Site Settings. Be sure to approve users in the Pending Users list. (This message will go away after 2 days.)" - site_contact_username_warning: "The site_contact_username setting is blank. Please update it in the Site Settings. Set it to the username of an admin user who should be the sender of system messages." + site_contact_username_warning: "Enter the name of a friendly staff user account to send important automated private messages from, such as the new user welcome, flag warnings, etc. Update site_contact_username in Site Settings." notification_email_warning: "The notification_email setting is blank. Please update it in the Site Settings." content_types: @@ -657,9 +657,9 @@ en: allow_duplicate_topic_titles: "Allow topics with identical, duplicate titles." unique_posts_mins: "How many minutes before a user can make a post with the same content again" educate_until_posts: "When the user starts typing their first (n) new posts, show the pop-up new user education panel in the composer." - title: "Brief title of this site, used in the title tag." - site_description: "Describe this site in one sentence, used in the meta description tag." - contact_email: "Email address of key contact for site. Important notices from discourse.org regarding critical updates may be sent to this address." + title: "The name of this site, as used in the title tag." + site_description: "Describe this site in one sentence, as used in the meta description tag." + contact_email: "Email address of key contact responsible for this site. Used for critical notifications only, as well as on the /about contact form for urgent matters." queue_jobs: "DEVELOPER ONLY! WARNING! By default, queue jobs in sidekiq. If disabled, your site will be broken." crawl_images: "Retrieve images from remote URLs to insert the correct width and height dimensions." download_remote_images_to_local: "Convert remote images to local images by downloading them; this prevents broken images." @@ -737,7 +737,7 @@ en: post_menu_hidden_items: "The menu items to hide by default in the post menu unless an expansion ellipsis is clicked on." share_links: "Determine which items appear on the share dialog, and in what order." track_external_right_clicks: "Track external links that are right clicked (eg: open in new tab) disabled by default because it rewrites URLs" - site_contact_username: "All automated private messages will be from this user; if left blank the default System account will be used." + site_contact_username: "A valid staff username to send all automated private messages from. If left blank the default System account will be used." send_welcome_message: "Send all new users a welcome private message with a quick start guide." suppress_reply_directly_below: "Don't show the expandable reply count on a post when there is only a single reply directly below this post." suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post." diff --git a/config/routes.rb b/config/routes.rb index 81a8d29ef..0c883317b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -325,6 +325,7 @@ Discourse::Application.routes.draw do post "category/uploads" => "categories#upload" post "category/:category_id/move" => "categories#move" post "category/:category_id/notifications" => "categories#set_notifications" + put "category/:category_id/slug" => "categories#update_slug" get "c/:id/show" => "categories#show" get "c/:category.rss" => "list#category_feed", format: :rss diff --git a/db/migrate/20150102113309_clean_up_user_history.rb b/db/migrate/20150102113309_clean_up_user_history.rb new file mode 100644 index 000000000..8ddbe9be0 --- /dev/null +++ b/db/migrate/20150102113309_clean_up_user_history.rb @@ -0,0 +1,10 @@ +class CleanUpUserHistory < ActiveRecord::Migration + def up + # 'checked_for_custom_avatar' is not used anymore + # was removed in https://github.com/discourse/discourse/commit/6c1c8be79433f87bef9d768da7b8fa4ec9bb18d7 + UserHistory.where(action: UserHistory.actions[:checked_for_custom_avatar]).delete_all + end + + def down + end +end diff --git a/lib/composer_messages_finder.rb b/lib/composer_messages_finder.rb index 8d2865ac2..e61d0729d 100644 --- a/lib/composer_messages_finder.rb +++ b/lib/composer_messages_finder.rb @@ -26,9 +26,11 @@ class ComposerMessagesFinder if count < SiteSetting.educate_until_posts education_posts_text = I18n.t('education.until_posts', count: SiteSetting.educate_until_posts) - return {templateName: 'composer/education', - wait_for_typing: true, - body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text)) } + return { + templateName: 'composer/education', + wait_for_typing: true, + body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text)) + } end nil @@ -37,7 +39,11 @@ class ComposerMessagesFinder # New users have a limited number of replies in a topic def check_new_user_many_replies return unless replying? && @user.posted_too_much_in_topic?(@details[:topic_id]) - {templateName: 'composer/education', body: PrettyText.cook(I18n.t('education.too_many_replies', newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic)) } + + { + templateName: 'composer/education', + body: PrettyText.cook(I18n.t('education.too_many_replies', newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic)) + } end # Should a user be contacted to update their avatar? @@ -49,14 +55,14 @@ class ComposerMessagesFinder # We don't notify users who have avatars or who have been notified already. return if @user.uploaded_avatar_id || UserHistory.exists_for_user?(@user, :notified_about_avatar) - # Finally, we don't check users whose avatars haven't been examined - return unless UserHistory.exists_for_user?(@user, :checked_for_custom_avatar) - # If we got this far, log that we've nagged them about the avatar UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: @user.id ) # Return the message - {templateName: 'composer/education', body: PrettyText.cook(I18n.t('education.avatar', profile_path: "/users/#{@user.username_lower}")) } + { + templateName: 'composer/education', + body: PrettyText.cook(I18n.t('education.avatar', profile_path: "/users/#{@user.username_lower}")) + } end # Is a user replying too much in succession? @@ -87,10 +93,12 @@ class ComposerMessagesFinder target_user_id: @user.id, topic_id: @details[:topic_id] ) - {templateName: 'composer/education', - wait_for_typing: true, - extraClass: 'urgent', - body: PrettyText.cook(I18n.t('education.sequential_replies')) } + { + templateName: 'composer/education', + wait_for_typing: true, + extraClass: 'urgent', + body: PrettyText.cook(I18n.t('education.sequential_replies')) + } end def check_dominating_topic @@ -102,6 +110,7 @@ class ComposerMessagesFinder !UserHistory.exists_for_user?(@user, :notified_about_dominating_topic, topic_id: @details[:topic_id]) topic = Topic.find_by(id: @details[:topic_id]) + return if topic.blank? || topic.user_id == @user.id || topic.posts_count < SiteSetting.summary_posts_required || @@ -117,11 +126,12 @@ class ComposerMessagesFinder target_user_id: @user.id, topic_id: @details[:topic_id]) - - {templateName: 'composer/education', - wait_for_typing: true, - extraClass: 'urgent', - body: PrettyText.cook(I18n.t('education.dominating_topic', percent: (ratio * 100).round)) } + { + templateName: 'composer/education', + wait_for_typing: true, + extraClass: 'urgent', + body: PrettyText.cook(I18n.t('education.dominating_topic', percent: (ratio * 100).round)) + } end def check_reviving_old_topic @@ -136,20 +146,22 @@ class ComposerMessagesFinder topic.last_posted_at.nil? || topic.last_posted_at > SiteSetting.warn_reviving_old_topic_age.days.ago - {templateName: 'composer/education', - wait_for_typing: false, - extraClass: 'urgent', - body: PrettyText.cook(I18n.t('education.reviving_old_topic', days: (Time.zone.now - topic.last_posted_at).round / 1.day)) } + { + templateName: 'composer/education', + wait_for_typing: false, + extraClass: 'urgent', + body: PrettyText.cook(I18n.t('education.reviving_old_topic', days: (Time.zone.now - topic.last_posted_at).round / 1.day)) + } end private def creating_topic? - return @details[:composerAction] == "createTopic" + @details[:composerAction] == "createTopic" end def replying? - return @details[:composerAction] == "reply" + @details[:composerAction] == "reply" end end diff --git a/spec/components/composer_messages_finder_spec.rb b/spec/components/composer_messages_finder_spec.rb index f119bf4da..fdca2b683 100644 --- a/spec/components/composer_messages_finder_spec.rb +++ b/spec/components/composer_messages_finder_spec.rb @@ -83,44 +83,31 @@ describe ComposerMessagesFinder do let(:finder) { ComposerMessagesFinder.new(user, composerAction: 'createTopic') } let(:user) { Fabricate(:user) } - context "a user who we haven't checked for an avatar yet" do - it "returns no avatar message" do - finder.check_avatar_notification.should be_blank + context "success" do + let!(:message) { finder.check_avatar_notification } + + it "returns an avatar upgrade message" do + message.should be_present + end + + it "creates a notified_about_avatar log" do + UserHistory.exists_for_user?(user, :notified_about_avatar).should == true end end - context "a user who has been checked for a custom avatar" do - before do - UserHistory.create!(action: UserHistory.actions[:checked_for_custom_avatar], target_user_id: user.id ) - end + it "doesn't return notifications for new users" do + user.trust_level = TrustLevel[0] + finder.check_avatar_notification.should be_blank + end - context "success" do - let!(:message) { finder.check_avatar_notification } - - it "returns an avatar upgrade message" do - message.should be_present - end - - it "creates a notified_about_avatar log" do - UserHistory.exists_for_user?(user, :notified_about_avatar).should == true - end - end - - it "doesn't return notifications for new users" do - user.trust_level = TrustLevel[0] - finder.check_avatar_notification.should be_blank - end - - it "doesn't return notifications for users who have custom avatars" do - user.uploaded_avatar_id = 1 - finder.check_avatar_notification.should be_blank - end - - it "doesn't notify users who have been notified already" do - UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: user.id ) - finder.check_avatar_notification.should be_blank - end + it "doesn't return notifications for users who have custom avatars" do + user.uploaded_avatar_id = 1 + finder.check_avatar_notification.should be_blank + end + it "doesn't notify users who have been notified already" do + UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: user.id ) + finder.check_avatar_notification.should be_blank end end diff --git a/spec/components/concern/has_custom_fields_spec.rb b/spec/components/concern/has_custom_fields_spec.rb index c41f3ebc6..0b7cfb90b 100644 --- a/spec/components/concern/has_custom_fields_spec.rb +++ b/spec/components/concern/has_custom_fields_spec.rb @@ -111,10 +111,13 @@ describe HasCustomFields do db_item = CustomFieldsTestItem.find(test_item.id) db_item.custom_fields.should == {"a" => ["b", "c", "d"]} - db_item.custom_fields["a"] = ["c", "d"] + db_item.custom_fields.update('a' => ['c', 'd']) db_item.save db_item.custom_fields.should == {"a" => ["c", "d"]} + db_item.custom_fields.delete('a') + db_item.custom_fields.should == {} + end it "casts integers in arrays properly without error" do diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 31271da27..5ad68e9bb 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -460,6 +460,7 @@ describe Admin::UsersController do context ".invite_admin" do it 'should invite admin' do + Jobs.expects(:enqueue).with(:user_email, anything).returns(true) xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com' response.should be_success @@ -468,6 +469,14 @@ describe Admin::UsersController do u.username.should == "bill22" u.admin.should == true end + + it "doesn't send the email with send_email falsy" do + Jobs.expects(:enqueue).with(:user_email, anything).never + xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com', send_email: '0' + response.should be_success + json = ::JSON.parse(response.body) + json["password_url"].should be_present + end end end diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb index 9131980c1..0dd227c4b 100644 --- a/spec/controllers/categories_controller_spec.rb +++ b/spec/controllers/categories_controller_spec.rb @@ -203,4 +203,42 @@ describe CategoriesController do end + describe 'update_slug' do + it 'requires the user to be logged in' do + lambda { xhr :put, :update_slug, category_id: 'category'}.should raise_error(Discourse::NotLoggedIn) + end + + describe 'logged in' do + let(:valid_attrs) { {id: @category.id, slug: 'fff'} } + + before do + @user = log_in(:admin) + @category = Fabricate(:happy_category, user: @user) + end + + it 'rejects blank' do + xhr :put, :update_slug, category_id: @category.id, slug: nil + response.status.should == 422 + end + + it 'accepts valid custom slug' do + xhr :put, :update_slug, category_id: @category.id, slug: 'valid-slug' + response.should be_success + category = Category.find(@category.id) + category.slug.should == 'valid-slug' + end + + it 'accepts not well formed custom slug' do + xhr :put, :update_slug, category_id: @category.id, slug: ' valid slug' + response.should be_success + category = Category.find(@category.id) + category.slug.should == 'valid-slug' + end + + it 'rejects invalid custom slug' do + xhr :put, :update_slug, category_id: @category.id, slug: ' ' + response.status.should == 422 + end + end + end end diff --git a/spec/controllers/export_csv_controller_spec.rb b/spec/controllers/export_csv_controller_spec.rb index cb40652ee..1b9a7337a 100644 --- a/spec/controllers/export_csv_controller_spec.rb +++ b/spec/controllers/export_csv_controller_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe ExportCsvController do - let(:export_filename) { "export_999.csv" } + let(:export_filename) { "user-archive-999.csv" } context "while logged in as normal user" do @@ -30,7 +30,7 @@ describe ExportCsvController do describe ".download" do it "uses send_file to transmit the export file" do file = UserExport.create(export_type: "user", user_id: @user.id) - file_name = "export_#{file.id}.csv" + file_name = "user-archive-#{file.id}.csv" controller.stubs(:render) export = UserExport.new() UserExport.expects(:get_download_path).with(file_name).returns(export) @@ -74,7 +74,7 @@ describe ExportCsvController do describe ".download" do it "uses send_file to transmit the export file" do file = UserExport.create(export_type: "admin", user_id: @admin.id) - file_name = "export_#{file.id}.csv" + file_name = "screened-email-#{file.id}.csv" controller.stubs(:render) export = UserExport.new() UserExport.expects(:get_download_path).with(file_name).returns(export) diff --git a/spec/fabricators/category_fabricator.rb b/spec/fabricators/category_fabricator.rb index dae133774..1f237b5d6 100644 --- a/spec/fabricators/category_fabricator.rb +++ b/spec/fabricators/category_fabricator.rb @@ -7,3 +7,9 @@ Fabricator(:diff_category, from: :category) do name "Different Category" user end + +Fabricator(:happy_category, from: :category) do + name 'Happy Category' + slug 'happy' + user +end diff --git a/spec/jobs/export_csv_file_spec.rb b/spec/jobs/export_csv_file_spec.rb index 8c41b43bc..f0f05afc1 100644 --- a/spec/jobs/export_csv_file_spec.rb +++ b/spec/jobs/export_csv_file_spec.rb @@ -8,16 +8,16 @@ describe Jobs::ExportCsvFile do end end - let :user_header do + let :user_list_header do Jobs::ExportCsvFile.new.get_header('user') end - let :user_export do - Jobs::ExportCsvFile.new.user_export + let :user_list_export do + Jobs::ExportCsvFile.new.user_list_export end def to_hash(row) - Hash[*user_header.zip(row).flatten] + Hash[*user_list_header.zip(row).flatten] end it 'exports sso data' do @@ -25,10 +25,9 @@ describe Jobs::ExportCsvFile do user = Fabricate(:user) user.create_single_sign_on_record(external_id: "123", last_payload: "xxx", external_email: 'test@test.com') - user = to_hash(user_export.find{|u| u[0] == user.id}) + user = to_hash(user_list_export.find{|u| u[0] == user.id}) user["external_id"].should == "123" user["external_email"].should == "test@test.com" end end - diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 9e3cf3d0f..93b3cf5ec 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -198,6 +198,11 @@ describe Category do c.slug.should eq("cats-category") end + it 'and be sanitized' do + c = Fabricate(:category, name: 'Cats', slug: ' invalid slug') + c.slug.should == 'invalid-slug' + end + it 'fails if custom slug is duplicate with existing' do c1 = Fabricate(:category, name: "Cats", slug: "cats") c2 = Fabricate.build(:category, name: "More Cats", slug: "cats") diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index cf853c0d3..5b9bb5135 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -28,8 +28,8 @@ test("editingMode", function() { topicController.set('model.details.can_edit', true); topicController.send('editTopic'); ok(topicController.get('editingTopic'), "calling editTopic enables editing if the user can edit"); - equal(topicController.get('newTitle'), topic.get('title')); - equal(topicController.get('newCategoryId'), topic.get('category_id')); + equal(topicController.get('buffered.title'), topic.get('title')); + equal(topicController.get('buffered.category_id'), topic.get('category_id')); topicController.send('cancelEditingTopic'); ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value");