From 2da5d2311b303ab33c8ab47e35e63b4d19a23c96 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 2 Jan 2014 17:58:49 +1100 Subject: [PATCH] FEATURE: Added UI for adding and removing watched and muted categories --- .../components/category_group_component.js | 42 +++++++++++++++++++ .../discourse/helpers/application_helpers.js | 4 ++ .../javascripts/discourse/lib/autocomplete.js | 20 ++++++--- .../javascripts/discourse/models/category.js | 5 +++ .../javascripts/discourse/models/user.js | 23 ++++++++-- .../components/category-group.js.handlebars | 1 + .../templates/user/preferences.js.handlebars | 14 +++++++ app/assets/stylesheets/desktop/compose.scss | 2 +- app/assets/stylesheets/desktop/user.scss | 13 ++++++ app/models/category_user.rb | 28 +++++++++++++ app/serializers/user_serializer.rb | 12 +++++- app/services/user_updater.rb | 10 ++++- config/locales/client.en.yml | 5 +++ spec/models/category_user_spec.rb | 19 +++++++++ 14 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/category_group_component.js create mode 100644 app/assets/javascripts/discourse/templates/components/category-group.js.handlebars diff --git a/app/assets/javascripts/discourse/components/category_group_component.js b/app/assets/javascripts/discourse/components/category_group_component.js new file mode 100644 index 00000000000..28cc4ea4b72 --- /dev/null +++ b/app/assets/javascripts/discourse/components/category_group_component.js @@ -0,0 +1,42 @@ +Discourse.CategoryGroupComponent = Ember.Component.extend({ + + didInsertElement: function(){ + var self = this; + + this.$('input').autocomplete({ + items: this.get('categories'), + single: false, + allowAny: false, + dataSource: function(term){ + return Discourse.Category.list().filter(function(category){ + var regex = new RegExp(term, "i"); + return category.get("name").match(regex) && + !_.contains(self.get('categories'), category); + }); + }, + onChangeItems: function(items) { + self.set("categories", items); + }, + template: Discourse.CategoryGroupComponent.templateFunction(), + transformComplete: function(category){ + return Discourse.HTML.categoryLink(category); + } + }); + } + +}); + +Discourse.CategoryGroupComponent.reopenClass({ + templateFunction: function(){ + this.compiled = this.compiled || Handlebars.compile("
" + + "" + + "
"); + return this.compiled; + } +}); diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js index 90944c24397..0c2577831eb 100644 --- a/app/assets/javascripts/discourse/helpers/application_helpers.js +++ b/app/assets/javascripts/discourse/helpers/application_helpers.js @@ -65,6 +65,10 @@ Handlebars.registerHelper('categoryLink', function(property, options) { return categoryLinkHTML(Ember.Handlebars.get(this, property, options), options); }); +Handlebars.registerHelper('categoryLinkRaw', function(property, options) { + return categoryLinkHTML(property, options); +}); + /** Produces a bound link to a category diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js b/app/assets/javascripts/discourse/lib/autocomplete.js index f8148d0918c..33d3715a1eb 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/lib/autocomplete.js @@ -74,7 +74,6 @@ $.fn.autocomplete = function(options) { var isInput = this[0].tagName === "INPUT"; var inputSelectedItems = []; - var closeAutocomplete = function() { if (div) { div.hide().remove(); @@ -93,7 +92,7 @@ $.fn.autocomplete = function(options) { // dump what we have in single mode, just in case inputSelectedItems = []; } - var d = $("
" + (transformed || item) + "
"); + var d = $("
" + (transformed || item) + "
"); var prev = me.parent().find('.item:last'); if (prev.length === 0) { me.parent().prepend(d); @@ -158,6 +157,11 @@ $.fn.autocomplete = function(options) { addInputSelectedItem(x); } }); + if(options.items) { + _.each(options.items, function(item){ + addInputSelectedItem(item); + }); + } this.val(""); completeStart = 0; wrap.click(function() { @@ -225,8 +229,14 @@ $.fn.autocomplete = function(options) { }; var updateAutoComplete = function(r) { + if (completeStart === null) return; + if (r && r.then && typeof(r.then) === "function") { + r.then(updateAutoComplete); + return; + } + autocompleteOptions = r; if (!r || r.length === 0) { closeAutocomplete(); @@ -257,7 +267,7 @@ $.fn.autocomplete = function(options) { if (!prevChar || /\s/.test(prevChar)) { completeStart = completeEnd = caretPosition; var term = ""; - options.dataSource(term).then(updateAutoComplete); + updateAutoComplete(options.dataSource(term)); } } }); @@ -304,7 +314,7 @@ $.fn.autocomplete = function(options) { completeStart = c; caretPosition = completeEnd = initial; term = me[0].value.substring(c + 1, initial); - options.dataSource(term).then(updateAutoComplete); + updateAutoComplete(options.dataSource(term)); return true; } } @@ -395,7 +405,7 @@ $.fn.autocomplete = function(options) { } } - options.dataSource(term).then(updateAutoComplete); + updateAutoComplete(options.dataSource(term)); return true; } } diff --git a/app/assets/javascripts/discourse/models/category.js b/app/assets/javascripts/discourse/models/category.js index 5f68151d849..dc1a125a2f5 100644 --- a/app/assets/javascripts/discourse/models/category.js +++ b/app/assets/javascripts/discourse/models/category.js @@ -204,6 +204,11 @@ Discourse.Category.reopenClass({ }); }, + // TODO: optimise, slow for no real reason + findById: function(id){ + return Discourse.Category.list().findBy('id', id); + }, + findBySlug: function(slug, parentSlug) { var categories = Discourse.Category.list(), diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 1d622e0f39a..a41b24d03f0 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -167,8 +167,7 @@ Discourse.User = Discourse.Model.extend({ **/ save: function() { var user = this; - return Discourse.ajax("/users/" + this.get('username_lower'), { - data: this.getProperties('auto_track_topics_after_msecs', + var data = this.getProperties('auto_track_topics_after_msecs', 'bio_raw', 'website', 'name', @@ -181,7 +180,12 @@ Discourse.User = Discourse.Model.extend({ 'new_topic_duration_minutes', 'external_links_in_new_tab', 'watch_new_topics', - 'enable_quoting'), + 'enable_quoting'); + data.watched_category_ids = this.get('watchedCategories').map(function(c){ return c.get('id')}); + data.muted_category_ids = this.get('mutedCategories').map(function(c){ return c.get('id')}); + + return Discourse.ajax("/users/" + this.get('username_lower'), { + data: data, type: 'PUT' }).then(function(data) { user.set('bio_excerpt',data.user.bio_excerpt); @@ -350,8 +354,19 @@ Discourse.User = Discourse.Model.extend({ } } return Discourse.Utilities.defaultHomepage(); - }.property("trust_level", "hasBeenSeenInTheLastMonth") + }.property("trust_level", "hasBeenSeenInTheLastMonth"), + updateMutedCategories: function() { + this.set("mutedCategories", _.map(this.muted_category_ids, function(id){ + return Discourse.Category.findById(id); + })); + }.observes("muted_category_ids"), + + updateWatchedCategories: function() { + this.set("watchedCategories", _.map(this.watched_category_ids, function(id){ + return Discourse.Category.findById(id); + })); + }.observes("watched_category_ids") }); Discourse.User.reopenClass(Discourse.Singleton, { diff --git a/app/assets/javascripts/discourse/templates/components/category-group.js.handlebars b/app/assets/javascripts/discourse/templates/components/category-group.js.handlebars new file mode 100644 index 00000000000..e3c47acdff7 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/category-group.js.handlebars @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars index a39c245a4fa..1c913011bf8 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -134,6 +134,20 @@ +
+ +
+ + {{category-group categories=watchedCategories}} +
{{i18n user.watched_categories_instructions}}
+
+
+ + {{category-group categories=mutedCategories}} +
{{i18n user.muted_categories_instructions}}
+
+
+
diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 30a63b3fad4..f8be996d397 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -387,7 +387,7 @@ div.ac-wrap { line-height: 22px; vertical-align: bottom; } - a { + a.remove { margin-left: 4px; font-size: 10px; line-height: 10px; diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 8d7040391ab..445e96461ca 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -3,6 +3,19 @@ @import "common/foundation/mixins"; .user-preferences { + input.category-group { + width: 500px; + } + + .autocomplete .badge-category { + margin: 2px; + font-weight: normal; + } + + .autocomplete .badge-category.selected { + font-weight: bold; + } + textarea { width: 530px; height: 100px; diff --git a/app/models/category_user.rb b/app/models/category_user.rb index aef7760284c..ea59fa3c0bf 100644 --- a/app/models/category_user.rb +++ b/app/models/category_user.rb @@ -2,6 +2,10 @@ class CategoryUser < ActiveRecord::Base belongs_to :category belongs_to :user + def self.lookup(user, level) + self.where(user: user, notification_level: notification_levels[level]) + end + # same for now def self.notification_levels TopicUser.notification_levels @@ -15,6 +19,21 @@ class CategoryUser < ActiveRecord::Base ) end + def self.batch_set(user, level, category_ids) + records = CategoryUser.where(user: user, notification_level: notification_levels[level]) + + old_ids = records.pluck(:category_id) + + remove = (old_ids - category_ids) + if remove.present? + records.where('category_id in (?)', remove).destroy_all + end + + (category_ids - old_ids).each do |id| + CategoryUser.create!(user: user, category_id: id, notification_level: notification_levels[level]) + end + end + def self.auto_mute_new_topic(topic) apply_default_to_topic( topic, @@ -23,6 +42,15 @@ class CategoryUser < ActiveRecord::Base ) end + def notification_level1=(val) + val = Symbol === val ? CategoryUser.notification_levels[val] : val + attributes[:notification_level] = val + end + + def notification_level1 + attributes[:notification_level] + end + private def self.apply_default_to_topic(topic, level, reason) diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index d2830a9d21f..56a4af44510 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -60,7 +60,9 @@ class UserSerializer < BasicUserSerializer :use_uploaded_avatar, :has_uploaded_avatar, :gravatar_template, - :uploaded_avatar_template + :uploaded_avatar_template, + :muted_category_ids, + :watched_category_ids def auto_track_topics_after_msecs @@ -101,8 +103,16 @@ class UserSerializer < BasicUserSerializer def include_suspend_reason? object.suspended? end + def include_suspended_till? object.suspended? end + def muted_category_ids + CategoryUser.lookup(object, :muted).pluck(:category_id) + end + + def watched_category_ids + CategoryUser.lookup(object, :watching).pluck(:category_id) + end end diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 6066f378ae5..6fbffd03c78 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -11,8 +11,16 @@ class UserUpdater user.name = attributes[:name] || user.name user.digest_after_days = attributes[:digest_after_days] || user.digest_after_days + if ids = attributes[:watched_category_ids] + CategoryUser.batch_set(user, :watching, ids) + end + + if ids = attributes[:muted_category_ids] + CategoryUser.batch_set(user, :muted, ids) + end + if attributes[:auto_track_topics_after_msecs] - user.auto_track_topics_after_msecs = attributes[:auto_track_topics_after_msecs].to_i + user.auto_track_topics_after_msecs = attributes[:auto_track_topics_after_msecs].to_i end if attributes[:new_topic_duration_minutes] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 35971134597..21c9292ca92 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -228,6 +228,10 @@ en: suspended_notice: "This user is suspended until {{date}}." suspended_reason: "Reason: " watch_new_topics: "Automatically watch all new topics posted on the forum" + watched_categories: "Watched" + watched_categories_instructions: "You will automatically watch all topics in these categories" + muted_categories: "Muted" + muted_categories_instructions: "You will automatically mute all topics in these categories" messages: all: "All" @@ -313,6 +317,7 @@ en: email_always: "Receive email notifications and email digests even if I am active on the forum" other_settings: "Other" + categories_settings: "Categories" new_topic_duration: label: "Consider topics new when" diff --git a/spec/models/category_user_spec.rb b/spec/models/category_user_spec.rb index aff454adfd5..ab64fcaba4a 100644 --- a/spec/models/category_user_spec.rb +++ b/spec/models/category_user_spec.rb @@ -4,6 +4,25 @@ require 'spec_helper' require_dependency 'post_creator' describe CategoryUser do + + it 'allows batch set' do + user = Fabricate(:user) + category1 = Fabricate(:category) + category2 = Fabricate(:category) + + watching = CategoryUser.where(user_id: user.id, notification_level: CategoryUser.notification_levels[:watching]) + + CategoryUser.batch_set(user, :watching, [category1.id, category2.id]) + watching.pluck(:category_id).sort.should == [category1.id, category2.id] + + CategoryUser.batch_set(user, :watching, []) + watching.count.should == 0 + + CategoryUser.batch_set(user, :watching, [category2.id]) + watching.count.should == 1 + end + + context 'integration' do before do ActiveRecord::Base.observers.enable :all