UX: new /categories layout

This commit is contained in:
Régis Hanol 2016-08-17 23:23:16 +02:00
parent 36f0bd36f4
commit 6d1d7b7c8f
22 changed files with 273 additions and 446 deletions

View File

@ -24,7 +24,7 @@ registerUnbound('number', (orig, params) => {
// Round off the thousands to one decimal place
const n = number(orig);
if (n !== title) {
if (n.toString() !== title.toString() && !params.noTitle) {
result += " title='" + Handlebars.Utils.escapeExpression(title) + "'";
}
result += ">" + n + "</span>";

View File

@ -14,6 +14,17 @@ CategoryList.reopenClass({
const users = Discourse.Model.extractByKey(result.featured_users, Discourse.User);
const list = Discourse.Category.list();
let statPeriod;
const minCategories = result.category_list.categories.length * 0.8;
["week", "month"].some(period => {
const filteredCategories = result.category_list.categories.filter(c => c[`topics_${period}`] > 0);
if (filteredCategories.length >= minCategories) {
statPeriod = period;
return true;
}
});
result.category_list.categories.forEach(c => {
if (c.parent_category_id) {
c.parentCategory = list.findBy('id', c.parent_category_id);
@ -31,6 +42,22 @@ CategoryList.reopenClass({
c.topics = c.topics.map(t => Discourse.Topic.create(t));
}
switch(statPeriod) {
case "week":
case "month":
const stat = c[`topics_${statPeriod}`];
const unit = I18n.t(statPeriod);
if (stat > 0) {
c.stat = `<span class="value">${stat}</span> / <span class="unit">${unit}</span>`;
c.statTitle = I18n.t("categories.topic_stat_sentence", { count: stat, unit: unit });
break;
}
default:
c.stat = `<span class="value">${c.topic_count}</span>`;
c.statTitle = I18n.t("categories.topic_sentence", { count: c.topic_count });
break;
}
categories.pushObject(store.createRecord('category', c));
});
return categories;

View File

@ -1,5 +1,6 @@
import { ajax } from 'discourse/lib/ajax';
import RestModel from 'discourse/models/rest';
import computed from 'ember-addons/ember-computed-decorators';
import { on } from 'ember-addons/ember-computed-decorators';
import PermissionType from 'discourse/models/permission-type';
@ -17,56 +18,64 @@ const Category = RestModel.extend({
availableGroups.removeObject(elem.group_name);
return {
group_name: elem.group_name,
permission: PermissionType.create({id: elem.permission_type})
permission: PermissionType.create({ id: elem.permission_type })
};
}));
}
},
availablePermissions: function(){
return [ PermissionType.create({id: PermissionType.FULL}),
PermissionType.create({id: PermissionType.CREATE_POST}),
PermissionType.create({id: PermissionType.READONLY})
];
}.property(),
@computed
availablePermissions() {
return [
PermissionType.create({ id: PermissionType.FULL }),
PermissionType.create({ id: PermissionType.CREATE_POST }),
PermissionType.create({ id: PermissionType.READONLY })
];
},
searchContext: function() {
return ({ type: 'category', id: this.get('id'), category: this });
}.property('id'),
@computed("id")
searchContext(id) {
return { type: 'category', id, category: this };
},
url: function() {
@computed("name")
url() {
return Discourse.getURL("/c/") + Category.slugFor(this);
}.property('name'),
},
fullSlug: function() {
return this.get("url").slice(3).replace("/", "-");
}.property("url"),
@computed("url")
fullSlug(url) {
return url.slice(3).replace("/", "-");
},
nameLower: function() {
return this.get('name').toLowerCase();
}.property('name'),
@computed("name")
nameLower(name) {
return name.toLowerCase();
},
unreadUrl: function() {
return this.get('url') + '/l/unread';
}.property('url'),
@computed("url")
unreadUrl(url) {
return `${url}/l/unread`;
},
newUrl: function() {
return this.get('url') + '/l/new';
}.property('url'),
@computed("url")
newUrl(url) {
return `${url}/l/new`;
},
style: function() {
return "background-color: #" + this.get('category.color') + "; color: #" + this.get('category.text_color') + ";";
}.property('color', 'text_color'),
@computed("color", "text_color")
style(color, textColor) {
return `background-color: #${color}; color: #${textColor}`;
},
moreTopics: function() {
return this.get('topic_count') > Discourse.SiteSettings.category_featured_topics;
}.property('topic_count'),
@computed("topic_count")
moreTopics(topicCount) {
return topicCount > Discourse.SiteSettings.category_featured_topics;
},
save: function() {
var url = "/categories";
if (this.get('id')) {
url = "/categories/" + this.get('id');
}
save() {
const id = this.get("id");
const url = id ? `/categories/${id}` : "/categories";
return ajax(url, {
data: {
@ -91,111 +100,74 @@ const Category = RestModel.extend({
allowed_tags: this.get('allowed_tags'),
allowed_tag_groups: this.get('allowed_tag_groups')
},
type: this.get('id') ? 'PUT' : 'POST'
type: id ? 'PUT' : 'POST'
});
},
permissionsForUpdate: function(){
var rval = {};
_.each(this.get("permissions"),function(p){
rval[p.group_name] = p.permission.id;
});
@computed("permissions")
permissionsForUpdate(permissions) {
let rval = {};
permissions.forEach(p => rval[p.group_name] = p.permission.id);
return rval;
}.property("permissions"),
destroy: function() {
return ajax("/categories/" + (this.get('id') || this.get('slug')), { type: 'DELETE' });
},
addPermission: function(permission){
destroy() {
return ajax(`/categories/${this.get('id') || this.get('slug')}`, { type: 'DELETE' });
},
addPermission(permission) {
this.get("permissions").addObject(permission);
this.get("availableGroups").removeObject(permission.group_name);
},
removePermission: function(permission){
removePermission(permission) {
this.get("permissions").removeObject(permission);
this.get("availableGroups").addObject(permission.group_name);
},
permissions: function(){
@computed
permissions() {
return Em.A([
{group_name: "everyone", permission: PermissionType.create({id: 1})},
{group_name: "admins", permission: PermissionType.create({id: 2}) },
{group_name: "crap", permission: PermissionType.create({id: 3}) }
{ group_name: "everyone", permission: PermissionType.create({id: 1}) },
{ group_name: "admins", permission: PermissionType.create({id: 2}) },
{ group_name: "crap", permission: PermissionType.create({id: 3}) }
]);
}.property(),
},
latestTopic: function(){
var topics = this.get('topics');
@computed("topics")
latestTopic(topics) {
if (topics && topics.length) {
return topics[0];
}
}.property("topics"),
},
featuredTopics: function() {
var topics = this.get('topics');
@computed("topics")
featuredTopics(topics) {
if (topics && topics.length) {
return topics.slice(0, Discourse.SiteSettings.category_featured_topics || 2);
}
}.property('topics'),
},
unreadTopics: function() {
return this.topicTrackingState.countUnread(this.get('id'));
}.property('topicTrackingState.messageCount'),
@computed("id", "topicTrackingState.messageCount")
unreadTopics(id) {
return this.topicTrackingState.countUnread(id);
},
newTopics: function() {
return this.topicTrackingState.countNew(this.get('id'));
}.property('topicTrackingState.messageCount'),
@computed("id", "topicTrackingState.messageCount")
newTopics(id) {
return this.topicTrackingState.countNew(id);
},
topicStatsTitle: function() {
var string = I18n.t('categories.topic_stats');
_.each(this.get('topicCountStats'), function(stat) {
string += ' ' + I18n.t('categories.topic_stat_sentence', {count: stat.value, unit: stat.unit});
}, this);
return string;
}.property('post_count'),
postStatsTitle: function() {
var string = I18n.t('categories.post_stats');
_.each(this.get('postCountStats'), function(stat) {
string += ' ' + I18n.t('categories.post_stat_sentence', {count: stat.value, unit: stat.unit});
}, this);
return string;
}.property('post_count'),
topicCountStats: function() {
return this.countStats('topics');
}.property('topics_year', 'topics_month', 'topics_week', 'topics_day'),
setNotification: function(notification_level) {
var url = "/category/" + this.get('id')+"/notifications";
setNotification(notification_level) {
this.set('notification_level', notification_level);
return ajax(url, {
data: {
notification_level: notification_level
},
type: 'POST'
});
const url = `/category/${this.get('id')}/notifications`;
return ajax(url, { data: { notification_level }, type: 'POST' });
},
postCountStats: function() {
return this.countStats('posts');
}.property('posts_year', 'posts_month', 'posts_week', 'posts_day'),
countStats: function(prefix) {
var stats = [], val;
_.each(['day', 'week', 'month', 'year'], function(unit) {
val = this.get(prefix + '_' + unit);
if (val > 0) stats.pushObject({value: val, unit: I18n.t(unit)});
if (stats.length === 2) return false;
}, this);
return stats;
},
isUncategorizedCategory: function() {
return this.get('id') === Discourse.Site.currentProp("uncategorized_category_id");
}.property('id')
@computed("id")
isUncategorizedCategory(id) {
return id === Discourse.Site.currentProp("uncategorized_category_id");
}
});
var _uncategorized;

View File

@ -71,6 +71,11 @@ const Topic = RestModel.extend({
I18n.t('last_post') + ": " + longDate(this.get('bumpedAt'));
}.property('bumpedAt'),
@computed('replyCount')
replyTitle(count) {
return I18n.t("posts_likes", { count });
},
createdAt: function() {
return new Date(this.get('created_at'));
}.property('created_at'),

View File

@ -2,7 +2,7 @@ import showModal from "discourse/lib/show-modal";
import OpenComposer from "discourse/mixins/open-composer";
import CategoryList from "discourse/models/category-list";
import { defaultHomepage } from 'discourse/lib/utilities';
import PreloadStore from 'preload-store';
import TopicList from "discourse/models/topic-list";
const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, {
renderTemplate() {
@ -15,10 +15,6 @@ const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, {
},
model() {
// TODO: Remove this and ensure server side does not supply `topic_list`
// if default page is categories
PreloadStore.remove("topic_list");
return CategoryList.list(this.store, 'categories').then(list => {
const tracking = this.topicTrackingState;
if (tracking) {
@ -35,6 +31,8 @@ const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, {
},
setupController(controller, model) {
TopicList.find("latest").then(result => model.set("topicList", result));
controller.set("model", model);
this.controllerFor("navigation/categories").setProperties({

View File

@ -1,10 +1,9 @@
{{#if model.categories}}
{{#discovery-categories refresh="refresh"}}
<table class='topic-list categories'>
<table class='categories topic-list'>
<thead>
<tr>
<th class='category'>{{i18n 'categories.category'}}</th>
<th class='latest'>{{i18n 'categories.latest'}}</th>
<th class='stats topics'>{{i18n 'categories.topics'}}</th>
</tr>
</thead>
@ -18,7 +17,6 @@
{{#if c.logo_url}}
{{category-logo-link category=c}}
{{/if}}
<div class="category-description">
{{{c.description_excerpt}}}
</div>
@ -33,27 +31,66 @@
</div>
{{/if}}
</td>
<td class="{{if c.archived 'archived'}} latest">
{{#each c.featuredTopics as |f|}}
{{featured-topic topic=f latestTopicOnly=latestTopicOnly action="showTopicEntrance"}}
{{/each}}
</td>
<td class='stats' title={{c.topicStatsTitle}}>
<table class="categoryStats">
<tbody>
{{#each c.topicCountStats as |s|}}
<tr>
<td class="value">{{s.value}}</td>
<td class="unit"> / {{s.unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
<td class='stats' title={{c.statTitle}}>
{{{c.stat}}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/discovery-categories}}
<footer class='topic-list-bottom'></footer>
{{/if}}
<table class="topic-list topic-list-latest">
<thead>
<tr>
<th class='category'>{{i18n "filters.latest.title"}}</th>
</tr>
</thead>
<tbody>
{{#each model.topicList.topics as |t|}}
<tr>
<table>
<tbody>
<tr class="{{if t.archived 'archived'}}" data-topic-id={{unbound t.id}}>
<td class="topic-poster">
{{#with t.posters.lastObject.user as |lastPoster|}}
{{#user-link user=lastPoster}}
{{avatar lastPoster imageSize="large"}}
{{/user-link}}
{{/with}}
</td>
<td class="main-link">
<tr>
{{topic-status topic=t}}
{{topic-link t}}
{{#if t.unseen}}
<span class="badge-notification new-topic"></span>
{{/if}}
</tr>
<tr>
{{category-link t.category}}
{{#if t.tags}}
{{#each t.visibleListTags as |tag|}}
{{discourse-tag tag}}
{{/each}}
{{/if}}
</tr>
</td>
<td class="topic-stats">
<div class="topic-replies">
<a href="{{t.lastPostUrl}}" title="{{t.replyTitle}}">{{number t.replyCount noTitle="true"}}</a>
</div>
<div class="topic-last-activity">
<a href="{{t.lastPostUrl}}" title="{{t.bumpedAtTitle}}">{{format-date t.bumpedAt format="tiny" noTitle="true"}}</a>
</div>
</td>
</tr>
</tbody>
</table>
</tr>
{{else}}
{{loading-spinner}}
{{/each}}
</tbody>
</table>
<div class="clearfix"></div>

View File

@ -107,6 +107,41 @@ html.anon .topic-list a.title:visited:not(.badge-notification) {color: dark-ligh
}
.navigation-categories {
.topic-list {
width: 48%;
float: left;
}
.main-link {
width: 100%;
.discourse-tag {
font-size: 12px;
}
}
.topic-stats {
text-align: right;
a {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 40%));
}
}
.topic-replies {
font-weight: bold;
margin-bottom: 10px;
}
.topic-list-latest {
margin-left: 4%;
}
.topic-list.categories {
th.stats {
width: 20%;
}
.stats {
vertical-align: top;
text-align: center;
}
}
}
.topic-list.categories {

View File

@ -169,6 +169,7 @@ header .discourse-tag {color: $tag-color !important; }
.bullet + .list-tags {
display: block;
line-height: 15px;
}
.bar + .list-tags {
@ -247,4 +248,4 @@ header .discourse-tag {color: $tag-color !important; }
margin-right: 10px;
}
}
}
}

View File

@ -55,6 +55,8 @@
display: inline-flex;
align-items: baseline;
margin-right: 10px;
font-size: 12px;
line-height: 15px;
span.badge-category {
color: $primary !important;

View File

@ -193,8 +193,6 @@
}
}
.category{
width: 45%;
h3 {
display: inline-block;
font-size: 1.286em;

View File

@ -12,28 +12,29 @@ class CategoriesController < ApplicationController
end
def index
@description = SiteSetting.site_description
options = {}
options[:latest_posts] = params[:latest_posts] || SiteSetting.category_featured_topics
options[:parent_category_id] = params[:parent_category_id]
options[:is_homepage] = current_homepage == "categories".freeze
@list = CategoryList.new(guardian, options)
@list.draft_key = Draft::NEW_TOPIC
@list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC)
@list.draft = Draft.get(current_user, @list.draft_key, @list.draft_sequence) if current_user
discourse_expires_in 1.minute
unless current_homepage == "categories"
@title = I18n.t('js.filters.categories.title')
end
@description = SiteSetting.site_description
category_options = { is_homepage: current_homepage == "categories".freeze }
@category_list = CategoryList.new(guardian, category_options)
@category_list.draft_key = Draft::NEW_TOPIC
@category_list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC)
@category_list.draft = Draft.get(current_user, @category_list.draft_key, @category_list.draft_sequence) if current_user
@title = I18n.t('js.filters.categories.title') unless category_options[:is_homepage]
store_preloaded("categories_list", MultiJson.dump(CategoryListSerializer.new(@list, scope: guardian)))
respond_to do |format|
format.html { render }
format.json { render_serialized(@list, CategoryListSerializer) }
format.html do
topic_options = { per_page: SiteSetting.categories_topics, no_definitions: true }
topic_list = TopicQuery.new(current_user, topic_options).list_latest
store_preloaded(topic_list.preload_key, MultiJson.dump(TopicListSerializer.new(topic_list, scope: guardian)))
store_preloaded(@category_list.preload_key, MultiJson.dump(CategoryListSerializer.new(@category_list, scope: guardian)))
render
end
format.json { render_serialized(@category_list, CategoryListSerializer) }
end
end

View File

@ -87,7 +87,7 @@ class Category < ActiveRecord::Base
# permission is just used by serialization
# we may consider wrapping this in another spot
attr_accessor :displayable_topics, :permission, :subcategory_ids, :notification_level, :has_children
attr_accessor :permission, :subcategory_ids, :notification_level, :has_children
@topic_id_cache = DistributedCache.new('category_topic_ids')
@ -187,9 +187,7 @@ SQL
self.topic_id ? query.where(['topics.id <> ?', self.topic_id]) : query
end
# Internal: Generate the text of post prompting to enter category
# description.
# Internal: Generate the text of post prompting to enter category description.
def self.post_template
I18n.t("category.post_template", replace_paragraph: I18n.t("category.replace_paragraph"))
end
@ -219,7 +217,6 @@ SQL
@@cache.getset(self.description) do
Nokogiri::HTML(self.description).text
end
end
def duplicate_slug?

View File

@ -1,75 +1,37 @@
require_dependency 'pinned_check'
class CategoryList
include ActiveModel::Serialization
attr_accessor :categories,
:topic_users,
:uncategorized,
:draft,
:draft_key,
:draft_sequence
def initialize(guardian=nil, options = {})
def initialize(guardian=nil, options={})
@guardian = guardian || Guardian.new
@options = options
find_relevant_topics unless latest_post_only?
find_categories
end
prune_empty
find_user_data
sort_unpinned
trim_results
def preload_key
"categories_list".freeze
end
private
def latest_post_only?
@options[:latest_posts] and latest_posts_count == 1
end
def include_latest_posts?
@options[:latest_posts] and latest_posts_count > 1
end
def latest_posts_count
@options[:latest_posts].to_i > 0 ? @options[:latest_posts].to_i : SiteSetting.category_featured_topics
end
# Retrieve a list of all the topics we'll need
def find_relevant_topics
@topics_by_category_id = {}
category_featured_topics = CategoryFeaturedTopic.select([:category_id, :topic_id]).order(:rank)
@topics_by_id = {}
@all_topics = Topic.where(id: category_featured_topics.map(&:topic_id))
@all_topics = @all_topics.includes(:last_poster) if include_latest_posts?
@all_topics.each do |t|
t.include_last_poster = true if include_latest_posts? # hint for serialization
@topics_by_id[t.id] = t
end
category_featured_topics.each do |cft|
@topics_by_category_id[cft.category_id] ||= []
@topics_by_category_id[cft.category_id] << cft.topic_id
end
end
# Find a list of all categories to associate the topics with
def find_categories
@categories = Category
.includes(:featured_users, :topic_only_relative_url, subcategories: [:topic_only_relative_url])
.secured(@guardian)
if @options[:parent_category_id].present?
@categories = @categories.where('categories.parent_category_id = ?', @options[:parent_category_id].to_i)
end
@categories = Category.includes(:topic_only_relative_url, subcategories: [:topic_only_relative_url]).secured(@guardian)
@categories = @categories.where(suppress_from_homepage: false) if @options[:is_homepage]
unless SiteSetting.allow_uncategorized_topics
# TODO: also make sure the uncategorized is empty
@categories = @categories.where("id <> #{SiteSetting.uncategorized_category_id}")
end
if SiteSetting.fixed_category_positions
@categories = @categories.order('position ASC').order('id ASC')
@categories = @categories.order(:position, :id)
else
@categories = @categories.order('COALESCE(categories.posts_week, 0) DESC')
.order('COALESCE(categories.posts_month, 0) DESC')
@ -77,10 +39,6 @@ class CategoryList
.order('id ASC')
end
if latest_post_only?
@categories = @categories.includes(latest_post: { topic: :last_poster })
end
@categories = @categories.to_a
category_user = {}
@ -95,95 +53,18 @@ class CategoryList
category.has_children = category.subcategories.present?
end
if @options[:parent_category_id].blank?
subcategories = {}
to_delete = Set.new
@categories.each do |c|
if c.parent_category_id.present?
subcategories[c.parent_category_id] ||= []
subcategories[c.parent_category_id] << c.id
to_delete << c
end
end
if subcategories.present?
@categories.each do |c|
c.subcategory_ids = subcategories[c.id]
end
@categories.delete_if {|c| to_delete.include?(c) }
end
end
if latest_post_only?
@all_topics = []
@categories.each do |c|
if c.latest_post && c.latest_post.topic && @guardian.can_see?(c.latest_post.topic)
c.displayable_topics = [c.latest_post.topic]
topic = c.latest_post.topic
topic.include_last_poster = true # hint for serialization
@all_topics << topic
end
end
end
if @topics_by_category_id
@categories.each do |c|
topics_in_cat = @topics_by_category_id[c.id]
if topics_in_cat.present?
c.displayable_topics = []
topics_in_cat.each do |topic_id|
topic = @topics_by_id[topic_id]
if topic.present? && @guardian.can_see?(topic)
# topic.category is very slow under rails 4.2
topic.association(:category).target = c
c.displayable_topics << topic
end
end
end
end
end
end
def prune_empty
unless SiteSetting.allow_uncategorized_topics
# HACK: Don't show uncategorized to anyone if not allowed
@categories.delete_if do |c|
c.uncategorized? && c.displayable_topics.blank?
end
end
end
# Get forum topic user records if appropriate
def find_user_data
if @guardian.current_user && @all_topics.present?
topic_lookup = TopicUser.lookup_for(@guardian.current_user, @all_topics)
# Attach some data for serialization to each topic
@all_topics.each { |ft| ft.user_data = topic_lookup[ft.id] }
end
end
def sort_unpinned
if @guardian.current_user && @all_topics.present?
# Put unpinned topics at the end of the list
@categories.each do |c|
next if c.displayable_topics.blank? || c.displayable_topics.size <= latest_posts_count
unpinned = []
c.displayable_topics.each do |t|
unpinned << t if t.pinned_at && PinnedCheck.unpinned?(t, t.user_data)
end
unless unpinned.empty?
c.displayable_topics = (c.displayable_topics - unpinned) + unpinned
end
end
end
end
def trim_results
subcategories = {}
to_delete = Set.new
@categories.each do |c|
next if c.displayable_topics.blank?
c.displayable_topics = c.displayable_topics[0,latest_posts_count]
if c.parent_category_id.present?
subcategories[c.parent_category_id] ||= []
subcategories[c.parent_category_id] << c.id
to_delete << c
end
end
@categories.each { |c| c.subcategory_ids = subcategories[c.id] }
@categories.delete_if { |c| to_delete.include?(c) }
end
end

View File

@ -6,17 +6,10 @@ class CategoryDetailedSerializer < BasicCategorySerializer
:topics_week,
:topics_month,
:topics_year,
:posts_day,
:posts_week,
:posts_month,
:posts_year,
:description_excerpt,
:is_uncategorized,
:subcategory_ids
has_many :featured_users, serializer: BasicUserSerializer
has_many :displayable_topics, serializer: ListableTopicSerializer, embed: :objects, key: :topics
def is_uncategorized
object.id == SiteSetting.uncategorized_category_id
end
@ -25,20 +18,14 @@ class CategoryDetailedSerializer < BasicCategorySerializer
is_uncategorized
end
def include_displayable_topics?
displayable_topics.present?
end
def description_excerpt
PrettyText.excerpt(description,300) if description
PrettyText.excerpt(description, 300) if description
end
def include_subcategory_ids?
subcategory_ids.present?
end
# Topic and post counts, including counts from the sub-categories:
def topics_day
count_with_subcategories(:topics_day)
end
@ -55,22 +42,6 @@ class CategoryDetailedSerializer < BasicCategorySerializer
count_with_subcategories(:topics_year)
end
def posts_day
count_with_subcategories(:posts_day)
end
def posts_week
count_with_subcategories(:posts_week)
end
def posts_month
count_with_subcategories(:posts_month)
end
def posts_year
count_with_subcategories(:posts_year)
end
def count_with_subcategories(method)
count = object.send(method) || 0
object.subcategories.each do |category|

View File

@ -1,24 +1,12 @@
<div class='category-list' itemscope itemtype='http://schema.org/ItemList'>
<meta itemprop='itemListOrder' content='http://schema.org/ItemListOrderDescending'>
<% @list.categories.each do |c| %>
<% @category_list.categories.each do |c| %>
<div class='category' itemprop='itemListElement' itemscope itemtype='http://schema.org/ListItem'>
<meta itemprop='url' content='<%= c.url %>'>
<h2><a href='<%= c.url %>' itemprop='item'>
<span itemprop='name'><%= c.name %></span>
</a></h2>
<span itemprop='description'><%= c.description %></span>
<div class='topic-list' itemscope itemtype='http://schema.org/ItemList'>
<%- if c.displayable_topics.present? %>
<% c.displayable_topics.each do |t| %>
<div itemprop='itemListElement' itemscope itemtype='http://schema.org/ListItem'>
<meta itemprop='url' content='<%= t.url %>'>
<a href='<%= t.relative_url %>' itemprop='item'>
<span itemprop='name'><%= t.title %></span>
</a> <span title='<%= t 'posts' %>'>(<%= t.posts_count %>)</span>
</div>
<% end %>
<%- end %>
</div>
</div>
<% end %>
</div>

View File

@ -458,14 +458,12 @@ en:
latest_by: "latest by"
toggle_ordering: "toggle ordering control"
subcategories: "Subcategories"
topic_stats: "The number of new topics."
topic_sentence:
one: "1 topic"
other: "%{count} topics"
topic_stat_sentence:
one: "%{count} new topic in the past %{unit}."
other: "%{count} new topics in the past %{unit}."
post_stats: "The number of new posts."
post_stat_sentence:
one: "%{count} new post in the past %{unit}."
other: "%{count} new posts in the past %{unit}."
ip_lookup:
title: IP Address Lookup
@ -1941,6 +1939,10 @@ en:
posts: "Posts"
posts_long: "there are {{number}} posts in this topic"
posts_likes:
one: "This topic has 1 reply."
other: "This topic has {{count}} replies."
# keys ending with _MF use message format, see https://meta.discourse.org/t/message-format-support-for-localization/7035 for details
posts_likes_MF: |
This topic has {count, plural, one {1 reply} other {# replies}} {ratio, select,

View File

@ -1055,6 +1055,7 @@ en:
alert_admins_if_errors_per_minute: "Number of errors per minute in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart."
alert_admins_if_errors_per_hour: "Number of errors per hour in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart."
categories_topics: "Number of topics to show in /categories page."
suggested_topics: "Number of suggested topics shown at the bottom of a topic."
limit_suggested_to_category: "Only show topics from the current category in suggested topics."
suggested_topics_max_days_old: "Suggested topics should not be more than n days old."

View File

@ -71,6 +71,9 @@ basic:
set_locale_from_accept_language_header:
default: false
validator: "AllowUserLocaleEnabledValidator"
categories_topics:
default: 20
min: 5
suggested_topics:
client: true
default: 5

View File

@ -297,7 +297,6 @@ class TopicQuery
end
topics.each do |t|
t.allowed_user_ids = filter == :private_messages ? t.allowed_users.map{|u| u.id} : []
end

View File

@ -19,32 +19,6 @@ describe CategoryList do
expect(CategoryList.new(Guardian.new user).categories.count).to eq(1)
expect(CategoryList.new(Guardian.new nil).categories.count).to eq(1)
end
it "doesn't show topics that you can't view" do
public_cat = Fabricate(:category) # public category
Fabricate(:topic, category: public_cat)
private_cat = Fabricate(:category) # private category
Fabricate(:topic, category: private_cat)
private_cat.set_permissions(admins: :full)
private_cat.save
secret_subcat = Fabricate(:category, parent_category_id: public_cat.id) # private subcategory
Fabricate(:topic, category: secret_subcat)
secret_subcat.set_permissions(admins: :full)
secret_subcat.save
CategoryFeaturedTopic.feature_topics
expect(CategoryList.new(Guardian.new(admin)).categories.find { |x| x.name == public_cat.name }.displayable_topics.count).to eq(2)
expect(CategoryList.new(Guardian.new(admin)).categories.find { |x| x.name == private_cat.name }.displayable_topics.count).to eq(1)
expect(CategoryList.new(Guardian.new(user)).categories.find { |x| x.name == public_cat.name }.displayable_topics.count).to eq(1)
expect(CategoryList.new(Guardian.new(user)).categories.find { |x| x.name == private_cat.name }).to eq(nil)
expect(CategoryList.new(Guardian.new(nil)).categories.find { |x| x.name == public_cat.name }.displayable_topics.count).to eq(1)
expect(CategoryList.new(Guardian.new(nil)).categories.find { |x| x.name == private_cat.name }).to eq(nil)
end
end
context "with a category" do
@ -63,27 +37,6 @@ describe CategoryList do
end
context "with pinned topics in a category" do
let!(:topic1) { Fabricate(:topic, category: topic_category, bumped_at: 8.minutes.ago) }
let!(:topic2) { Fabricate(:topic, category: topic_category, bumped_at: 5.minutes.ago) }
let!(:topic3) { Fabricate(:topic, category: topic_category, bumped_at: 2.minutes.ago) }
let!(:pinned) { Fabricate(:topic, category: topic_category, pinned_at: 10.minutes.ago, bumped_at: 10.minutes.ago) }
let(:category) { category_list.categories.find{|c| c.id == topic_category.id} }
before do
SiteSetting.stubs(:category_featured_topics).returns(2)
end
it "returns pinned topic first" do
expect(category.displayable_topics.map(&:id)).to eq([pinned.id, topic3.id])
end
it "returns topics in bumped_at order if pinned was unpinned" do
PinnedCheck.stubs(:unpinned?).returns(true)
expect(category.displayable_topics.map(&:id)).to eq([topic3.id, topic2.id])
end
end
end
describe 'category order' do

View File

@ -5,26 +5,20 @@ describe CategoryDetailedSerializer do
describe "counts" do
it "works for categories with no subcategories" do
no_subcats = Fabricate(:category, topics_year: 10, topics_month: 5, topics_day: 2, posts_year: 13, posts_month: 7, posts_day: 3)
no_subcats = Fabricate(:category, topics_year: 10, topics_month: 5, topics_day: 2)
json = CategoryDetailedSerializer.new(no_subcats, scope: Guardian.new, root: false).as_json
expect(json[:topics_year]).to eq(10)
expect(json[:topics_month]).to eq(5)
expect(json[:topics_day]).to eq(2)
expect(json[:posts_year]).to eq(13)
expect(json[:posts_month]).to eq(7)
expect(json[:posts_day]).to eq(3)
end
it "includes counts from subcategories" do
parent = Fabricate(:category, topics_year: 10, topics_month: 5, topics_day: 2, posts_year: 13, posts_month: 7, posts_day: 3)
subcategory = Fabricate(:category, parent_category_id: parent.id, topics_year: 1, topics_month: 1, topics_day: 1, posts_year: 1, posts_month: 1, posts_day: 1)
parent = Fabricate(:category, topics_year: 10, topics_month: 5, topics_day: 2)
subcategory = Fabricate(:category, parent_category_id: parent.id, topics_year: 1, topics_month: 1, topics_day: 1)
json = CategoryDetailedSerializer.new(parent, scope: Guardian.new, root: false).as_json
expect(json[:topics_year]).to eq(11)
expect(json[:topics_month]).to eq(6)
expect(json[:topics_day]).to eq(3)
expect(json[:posts_year]).to eq(14)
expect(json[:posts_month]).to eq(8)
expect(json[:posts_day]).to eq(4)
end
end

View File

@ -88,44 +88,6 @@ test('findByIds', function() {
deepEqual(Discourse.Category.findByIds([1,2,3]), _.values(categories));
});
test('postCountStats', function() {
const store = createStore();
const category1 = store.createRecord('category', {id: 1, slug: 'unloved', posts_year: 2, posts_month: 0, posts_week: 0, posts_day: 0}),
category2 = store.createRecord('category', {id: 2, slug: 'hasbeen', posts_year: 50, posts_month: 4, posts_week: 0, posts_day: 0}),
category3 = store.createRecord('category', {id: 3, slug: 'solastweek', posts_year: 250, posts_month: 200, posts_week: 50, posts_day: 0}),
category4 = store.createRecord('category', {id: 4, slug: 'hotstuff', posts_year: 500, posts_month: 280, posts_week: 100, posts_day: 22}),
category5 = store.createRecord('category', {id: 6, slug: 'empty', posts_year: 0, posts_month: 0, posts_week: 0, posts_day: 0});
let result = category1.get('postCountStats');
equal(result.length, 1, "should only show year");
equal(result[0].value, 2);
equal(result[0].unit, 'year');
result = category2.get('postCountStats');
equal(result.length, 2, "should show month and year");
equal(result[0].value, 4);
equal(result[0].unit, 'month');
equal(result[1].value, 50);
equal(result[1].unit, 'year');
result = category3.get('postCountStats');
equal(result.length, 2, "should show week and month");
equal(result[0].value, 50);
equal(result[0].unit, 'week');
equal(result[1].value, 200);
equal(result[1].unit, 'month');
result = category4.get('postCountStats');
equal(result.length, 2, "should show day and week");
equal(result[0].value, 22);
equal(result[0].unit, 'day');
equal(result[1].value, 100);
equal(result[1].unit, 'week');
result = category5.get('postCountStats');
equal(result.length, 0, "should show nothing");
});
test('search with category name', () => {
const store = createStore(),
category1 = store.createRecord('category', { id: 1, name: 'middle term', slug: 'different-slug' }),