diff --git a/app/assets/javascripts/discourse/components/utilities.js b/app/assets/javascripts/discourse/components/utilities.js index ac002240526..3b930ac7b0e 100644 --- a/app/assets/javascripts/discourse/components/utilities.js +++ b/app/assets/javascripts/discourse/components/utilities.js @@ -33,22 +33,26 @@ Discourse.Utilities = { } }, - // Create a badge like category link + /** + Create a badge-like category link + + @method categoryLink + @param {Discourse.Category} category the category whose link we want + @returns {String} the html category badge + **/ categoryLink: function(category) { if (!category) return ""; - var color = Em.get(category, 'color'); - var textColor = Em.get(category, 'text_color'); - var name = Em.get(category, 'name'); - var description = Em.get(category, 'description'); - - // Build the HTML link - var result = "" + name + ""; + return html + "style=\"background-color: #" + color + "; color: #" + textColor + ";\">" + name + ""; }, avatarUrl: function(template, size) { diff --git a/app/assets/javascripts/discourse/models/category.js b/app/assets/javascripts/discourse/models/category.js index efb5b71123a..793404853e9 100644 --- a/app/assets/javascripts/discourse/models/category.js +++ b/app/assets/javascripts/discourse/models/category.js @@ -132,18 +132,56 @@ Discourse.Category.reopenClass({ slugFor: function(category) { if (!category) return ""; - var id = Em.get(category, 'id'); - var slug = Em.get(category, 'slug'); - if (!slug || slug.trim().length === 0) return "" + id + "-category"; - return slug; + + var parentCategory = Em.get(category, 'parentCategory'), + result = ""; + + if (parentCategory) { + result = Discourse.Category.slugFor(parentCategory) + "/"; + } + + var id = Em.get(category, 'id'), + slug = Em.get(category, 'slug'); + + if (!slug || slug.trim().length === 0) return result + id + "-category"; + return result + slug; }, list: function() { return Discourse.Site.currentProp('categories'); }, - findBySlugOrId: function(slugOrId) { - // TODO: all our routing around categories need a rethink + findBySlug: function(slug, parentSlug) { + + var uncategorized = Discourse.Category.uncategorizedInstance(); + if (slug === uncategorized.get('slug')) return uncategorized; + + var categories = Discourse.Category.list(), + category; + + if (parentSlug) { + var parentCategory = categories.findBy('slug', parentSlug); + if (parentCategory) { + category = categories.find(function(item) { + return item && item.get('parentCategory') === parentCategory && item.get('slug') === slug; + }); + } + } else { + category = categories.findBy('slug', slug); + + // If we have a parent category, we need to enforce it + if (category.get('parentCategory')) return; + } + + // In case the slug didn't work, try to find it by id instead. + if (!category) { + category = categories.findBy('id', parseInt(slug, 10)); + } + + return category; + }, + + reloadBySlugOrId: function(slugOrId) { return Discourse.ajax("/category/" + slugOrId + "/show.json").then(function (result) { return Discourse.Category.create(result.category); }); diff --git a/app/assets/javascripts/discourse/models/category_list.js b/app/assets/javascripts/discourse/models/category_list.js index 31b4b9431fc..24761a1c5d7 100644 --- a/app/assets/javascripts/discourse/models/category_list.js +++ b/app/assets/javascripts/discourse/models/category_list.js @@ -24,18 +24,23 @@ Discourse.CategoryList = Ember.ArrayProxy.extend({ Discourse.CategoryList.reopenClass({ categoriesFrom: function(result) { - var categories = Discourse.CategoryList.create(); - var users = Discourse.Model.extractByKey(result.featured_users, Discourse.User); + var categories = Discourse.CategoryList.create(), + users = Discourse.Model.extractByKey(result.featured_users, Discourse.User), + list = Discourse.Category.list(); + result.category_list.categories.forEach(function(c) { + + if (c.parent_category_id) { + c.parentCategory = list.findBy('id', c.parent_category_id); + } - _.each(result.category_list.categories,function(c) { if (c.featured_user_ids) { - c.featured_users = _.map(c.featured_user_ids,function(u) { + c.featured_users = c.featured_user_ids.map(function(u) { return users[u]; }); } if (c.topics) { - c.topics = _.map(c.topics,function(t) { + c.topics = c.topics.map(function(t) { return Discourse.Topic.create(t); }); } @@ -58,8 +63,9 @@ Discourse.CategoryList.reopenClass({ }, list: function(filter) { - var self = this; - var finder = null; + var self = this, + finder = null; + if (filter === 'categories') { finder = PreloadStore.getAndRemove("categories_list", function() { return Discourse.ajax("/categories.json"); diff --git a/app/assets/javascripts/discourse/models/site.js b/app/assets/javascripts/discourse/models/site.js index b87424bdba4..40956e5b388 100644 --- a/app/assets/javascripts/discourse/models/site.js +++ b/app/assets/javascripts/discourse/models/site.js @@ -48,7 +48,7 @@ Discourse.Site.reopenClass(Discourse.Singleton, { var result = this._super(obj); if (result.categories) { - var byId = {} + var byId = {}; result.categories = _.map(result.categories, function(c) { byId[c.id] = Discourse.Category.create(c); return byId[c.id]; @@ -59,7 +59,7 @@ Discourse.Site.reopenClass(Discourse.Singleton, { if (c.get('parent_category_id')) { c.set('parentCategory', byId[c.get('parent_category_id')]); } - }) + }); } if (result.trust_levels) { diff --git a/app/assets/javascripts/discourse/models/topic_list.js b/app/assets/javascripts/discourse/models/topic_list.js index cbc83606b6f..97b6ea4ab73 100644 --- a/app/assets/javascripts/discourse/models/topic_list.js +++ b/app/assets/javascripts/discourse/models/topic_list.js @@ -83,9 +83,9 @@ Discourse.TopicList = Discourse.Model.extend({ Discourse.TopicList.reopenClass({ loadTopics: function(topic_ids, filter) { - var defer = new Ember.Deferred(); + var defer = new Ember.Deferred(), + url = Discourse.getURL("/") + filter + "?topic_ids=" + topic_ids.join(","); - var url = Discourse.getURL("/") + filter + "?topic_ids=" + topic_ids.join(","); Discourse.ajax({url: url}).then(function (result) { if (result) { // the new topics loaded from the server @@ -107,37 +107,45 @@ Discourse.TopicList.reopenClass({ return defer; }, + /** + Stitch together side loaded topic data + + @method topicsFrom + @param {Object} JSON object with topic data + @returns {Array} the list of topics + **/ topicsFrom: function(result) { // Stitch together our side loaded data - var categories, topics, users; - categories = this.extractByKey(result.categories, Discourse.Category); - users = this.extractByKey(result.users, Discourse.User); - topics = Em.A(); + var categories = Discourse.Category.list(), + users = this.extractByKey(result.users, Discourse.User), + topics = Em.A(); - _.each(result.topic_list.topics,function(ft) { - ft.category = categories[ft.category_id]; - _.each(ft.posters,function(p) { + return result.topic_list.topics.map(function (t) { + t.category = categories.findBy('id', t.category_id); + t.posters.forEach(function(p) { p.user = users[p.user_id]; }); - topics.pushObject(Discourse.Topic.create(ft)); + return Discourse.Topic.create(t); }); - return topics; }, + /** + Lists topics on a given menu item + + @method list + @param {Object} The menu item to filter to + @returns {Promise} a promise that resolves to the list of topics + **/ list: function(menuItem) { - var filter = menuItem.get('name'); + var filter = menuItem.get('name'), + session = Discourse.Session.current(), + list = session.get('topicList'); - var session = Discourse.Session.current(); - var list = session.get('topicList'); - if (list) { - if ((list.get('filter') === filter) && window.location.pathname.indexOf('more') > 0) { - list.set('loaded', true); - return Ember.RSVP.resolve(list); - } + if (list && (list.get('filter') === filter) && window.location.pathname.indexOf('more') > 0) { + list.set('loaded', true); + return Ember.RSVP.resolve(list); } - - session.set('topicList', null); - session.set('topicListScrollPos', null); + session.setProperties({topicList: null, topicListScrollPos: null}); return Discourse.TopicList.find(filter, menuItem.get('excludeCategory')); } diff --git a/app/assets/javascripts/discourse/routes/application_route.js b/app/assets/javascripts/discourse/routes/application_route.js index 973b75a3ed8..d7af47b2f0d 100644 --- a/app/assets/javascripts/discourse/routes/application_route.js +++ b/app/assets/javascripts/discourse/routes/application_route.js @@ -68,7 +68,7 @@ Discourse.ApplicationRoute = Em.Route.extend({ Discourse.Route.showModal(router, 'editCategory', category); router.controllerFor('editCategory').set('selectedTab', 'general'); } else { - Discourse.Category.findBySlugOrId(category.get('slug') || category.get('id')).then(function (c) { + Discourse.Category.reloadBySlugOrId(category.get('slug') || category.get('id')).then(function (c) { Discourse.Site.current().updateCategory(c); Discourse.Route.showModal(router, 'editCategory', c); router.controllerFor('editCategory').set('selectedTab', 'general'); diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js index 217439decac..7fd5c3756af 100644 --- a/app/assets/javascripts/discourse/routes/application_routes.js +++ b/app/assets/javascripts/discourse/routes/application_routes.js @@ -29,13 +29,14 @@ Discourse.Route.buildRoutes(function() { }); // the homepage is the first item of the 'top_menu' site setting - var settings = Discourse.SiteSettings; - var homepage = settings.top_menu.split("|")[0].split(",")[0]; + var homepage = Discourse.SiteSettings.top_menu.split("|")[0].split(",")[0]; this.route(homepage, { path: '/' }); this.route('categories', { path: '/categories' }); - this.route('category', { path: '/category/:slug/more' }); this.route('category', { path: '/category/:slug' }); + this.route('category', { path: '/category/:slug/more' }); + this.route('category', { path: '/category/:parentSlug/:slug' }); + this.route('category', { path: '/category/:parentSlug/:slug/more' }); }); // User routes diff --git a/app/assets/javascripts/discourse/routes/list_category_route.js b/app/assets/javascripts/discourse/routes/list_category_route.js index 631a4724258..38e2e264f84 100644 --- a/app/assets/javascripts/discourse/routes/list_category_route.js +++ b/app/assets/javascripts/discourse/routes/list_category_route.js @@ -9,21 +9,7 @@ Discourse.ListCategoryRoute = Discourse.FilteredListRoute.extend({ model: function(params) { - var categories = Discourse.Category.list(); - - var slug = Em.get(params, 'slug'); - - var uncategorized = Discourse.Category.uncategorizedInstance(); - if (slug === uncategorized.get('slug')) return uncategorized; - - var category = categories.findProperty('slug', Em.get(params, 'slug')); - - // In case the slug didn't work, try to find it by id instead. - if (!category) { - category = categories.findProperty('id', parseInt(slug, 10)); - } - - return category; + return Discourse.Category.findBySlug(Em.get(params, 'slug'), Em.get(params, 'parentSlug')); }, setupController: function(controller, category) { @@ -35,16 +21,18 @@ Discourse.ListCategoryRoute = Discourse.FilteredListRoute.extend({ } } - var listController = this.controllerFor('list'); - var urlId = Discourse.Category.slugFor(category); - listController.set('filterMode', "category/" + urlId); + var listController = this.controllerFor('list'), + urlId = Discourse.Category.slugFor(category), + self = this; - var router = this; + listController.set('filterMode', "category/" + urlId); listController.load("category/" + urlId).then(function(topicList) { - listController.set('canCreateTopic', topicList.get('can_create_topic')); - listController.set('category', category); - router.controllerFor('listTopics').set('content', topicList); - router.controllerFor('listTopics').set('category', category); + listController.setProperties({ + canCreateTopic: topicList.get('can_create_topic'), + category: category + }); + self.controllerFor('listTopics').set('content', topicList); + self.controllerFor('listTopics').set('category', category); }); }, diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index ccda31279bc..d9146bf5909 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -62,7 +62,12 @@ class ListController < ApplicationController @description = @category.description end - list.more_topics_url = url_for(category_list_path(params[:category], page: next_page, format: "json")) + if params[:parent_category].present? + list.more_topics_url = url_for(category_list_parent_path(params[:parent_category], params[:category], page: next_page, format: "json")) + else + list.more_topics_url = url_for(category_list_path(params[:category], page: next_page, format: "json")) + end + respond(list) end @@ -118,7 +123,18 @@ class ListController < ApplicationController def set_category slug = params.fetch(:category) - @category = Category.where("slug = ?", slug).includes(:featured_users).first || Category.where("id = ?", slug.to_i).includes(:featured_users).first + parent_slug = params[:parent_category] + + parent_category_id = nil + if parent_slug.present? + parent_category_id = Category.where(slug: parent_slug).pluck(:id).first || + Category.where(id: parent_slug.to_i).pluck(:id).first + + raise Discourse::NotFound.new if parent_category_id.blank? + end + + @category = Category.where(slug: slug, parent_category_id: parent_category_id).includes(:featured_users).first || + Category.where(id: slug.to_i, parent_category_id: parent_category_id).includes(:featured_users).first end def request_is_for_uncategorized? diff --git a/app/serializers/basic_category_serializer.rb b/app/serializers/basic_category_serializer.rb index 4b53d3f7179..60810ba6cee 100644 --- a/app/serializers/basic_category_serializer.rb +++ b/app/serializers/basic_category_serializer.rb @@ -13,4 +13,8 @@ class BasicCategorySerializer < ApplicationSerializer :permission, :parent_category_id + def include_parent_category_id? + parent_category_id + end + end diff --git a/app/serializers/category_detailed_serializer.rb b/app/serializers/category_detailed_serializer.rb index 0971b5840ad..b4b31b2176e 100644 --- a/app/serializers/category_detailed_serializer.rb +++ b/app/serializers/category_detailed_serializer.rb @@ -1,16 +1,9 @@ -class CategoryDetailedSerializer < ApplicationSerializer +class CategoryDetailedSerializer < BasicCategorySerializer - attributes :id, - :name, - :color, - :text_color, - :slug, - :topic_count, - :post_count, + attributes :post_count, :topics_week, :topics_month, :topics_year, - :description, :description_excerpt, :is_uncategorized diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index b8b4307e8d9..f90a96f7598 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -6,9 +6,9 @@ class TopicListItemSerializer < ListableTopicSerializer :has_best_of, :archetype, :rank_details, - :last_poster_username + :last_poster_username, + :category_id - has_one :category, serializer: BasicCategorySerializer has_many :posters, serializer: TopicPosterSerializer, embed: :objects def starred diff --git a/config/routes.rb b/config/routes.rb index 583c9dcfc05..2533fb16250 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,6 +196,7 @@ Discourse::Application.routes.draw do get 'category/:category.rss' => 'list#category_feed', format: :rss, as: 'category_feed' get 'category/:category' => 'list#category', as: 'category_list' + get 'category/:parent_category/:category' => 'list#category', as: 'category_list_parent' get 'category/:category/more' => 'list#category', as: 'category_list_more' # We've renamed popular to latest. If people access it we want a permanent redirect. diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb index d53636a94a6..5254ba508c1 100644 --- a/spec/controllers/list_controller_spec.rb +++ b/spec/controllers/list_controller_spec.rb @@ -92,6 +92,35 @@ describe ListController do end end + context 'a child category' do + let(:sub_category) { Fabricate(:category, parent_category_id: category.id) } + + context 'when parent and child are requested' do + before do + xhr :get, :category, parent_category: category.slug, category: sub_category.slug + end + + it { should respond_with(:success) } + end + + context 'when child is requested with the wrong parent' do + before do + xhr :get, :category, parent_category: 'not_the_right_slug', category: sub_category.slug + end + + it { should_not respond_with(:success) } + end + + context 'when child is requested without a parent' do + before do + xhr :get, :category, category: sub_category.slug + end + + it { should_not respond_with(:success) } + end + + end + describe 'feed' do it 'renders RSS' do get :category_feed, category: category.slug, format: :rss diff --git a/test/javascripts/models/category_test.js b/test/javascripts/models/category_test.js index 3943562955d..269d9039bee 100644 --- a/test/javascripts/models/category_test.js +++ b/test/javascripts/models/category_test.js @@ -2,13 +2,39 @@ module("Discourse.Category"); test('slugFor', function(){ - var slugFor = function(args, val, text) { - equal(Discourse.Category.slugFor(args), val, text); + var slugFor = function(cat, val, text) { + equal(Discourse.Category.slugFor(cat), val, text); }; - slugFor({slug: 'hello'}, "hello", "It calculates the proper slug for hello"); - slugFor({id: 123, slug: ''}, "123-category", "It returns id-category for empty strings"); - slugFor({id: 456}, "456-category", "It returns id-category for undefined slugs"); + slugFor(Discourse.Category.create({slug: 'hello'}), "hello", "It calculates the proper slug for hello"); + slugFor(Discourse.Category.create({id: 123, slug: ''}), "123-category", "It returns id-category for empty strings"); + slugFor(Discourse.Category.create({id: 456}), "456-category", "It returns id-category for undefined slugs"); + var parentCategory = Discourse.Category.create({id: 345, slug: 'darth'}); + slugFor(Discourse.Category.create({slug: 'luke', parentCategory: parentCategory}), + "darth/luke", + "it uses the parent slug before the child"); + + slugFor(Discourse.Category.create({id: 555, parentCategory: parentCategory}), + "darth/555-category", + "it uses the parent slug before the child and then uses id"); + + parentCategory.set('slug', null); + slugFor(Discourse.Category.create({id: 555, parentCategory: parentCategory}), + "345-category/555-category", + "it uses the parent before the child and uses ids for both"); }); + +test('findBySlug', function() { + var darth = Discourse.Category.create({id: 1, slug: 'darth'}), + luke = Discourse.Category.create({id: 2, slug: 'luke', parentCategory: darth}), + categoryList = [darth, luke]; + + this.stub(Discourse.Category, 'list').returns(categoryList); + + equal(Discourse.Category.findBySlug('darth'), darth, 'we can find a parent category'); + equal(Discourse.Category.findBySlug('luke', 'darth'), luke, 'we can find a child with parent'); + blank(Discourse.Category.findBySlug('luke'), 'luke is blank without the parent'); + blank(Discourse.Category.findBySlug('luke', 'leia'), 'luke is blank with an incorrect parent'); +}); \ No newline at end of file