Show Gaps in the post stream when filters are active

Conflicts:
	app/assets/javascripts/discourse/templates/topic.js.handlebars
This commit is contained in:
Robin Ward
2013-12-04 15:56:09 -05:00
parent 0fe5ecbb24
commit 79427732b2
14 changed files with 364 additions and 49 deletions

View File

@ -0,0 +1,48 @@
/**
Handles a gap between posts with a click to load more
@class PostGapComponent
@extends Ember.Component
@namespace Discourse
@module Discourse
**/
Discourse.PostGapComponent = Ember.Component.extend({
classNameBindings: [':gap', 'gap::hidden'],
init: function() {
this._super();
this.set('loading', false);
var before = this.get('before') === 'true',
gaps = before ? this.get('postStream.gaps.before') : this.get('postStream.gaps.after');
if (gaps) {
this.set('gap', gaps[this.get('post.id')]);
}
},
render: function(buffer) {
if (this.get('loading')) {
buffer.push(I18n.t('loading'));
} else {
buffer.push("<i class='icon icon-cut'></i>" + I18n.t('post.gap', {count: this.get('gap.length')}));
}
},
click: function() {
if (this.get('loading') || (!this.get('gap'))) { return false; }
this.set('loading', true);
this.rerender();
var self = this,
postStream = this.get('postStream'),
filler = this.get('before') === 'true' ? postStream.fillGapBefore : postStream.fillGapAfter;
filler.call(postStream, this.get('post'), this.get('gap')).then(function() {
// hide this control after the promise is resolved
self.set('gap', null);
});
return false;
}
});

View File

@ -500,6 +500,12 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
} }
}, },
/**
Called the the topmost visible post on the page changes.
@method topVisibleChanged
@params {Discourse.Post} post that is at the top
**/
topVisibleChanged: function(post) { topVisibleChanged: function(post) {
var postStream = this.get('postStream'), var postStream = this.get('postStream'),
firstLoadedPost = postStream.get('firstLoadedPost'); firstLoadedPost = postStream.get('firstLoadedPost');
@ -523,11 +529,18 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
} }
}, },
bottomVisibleChanged: function(post) { /**
this.set('progressPosition', post.get('post_number')); Called the the bottommost visible post on the page changes.
@method bottomVisibleChanged
@params {Discourse.Post} post that is at the bottom
**/
bottomVisibleChanged: function(post) {
var postStream = this.get('postStream'), var postStream = this.get('postStream'),
lastLoadedPost = postStream.get('lastLoadedPost'); lastLoadedPost = postStream.get('lastLoadedPost'),
index = postStream.get('stream').indexOf(post.get('id'))+1;
this.set('progressPosition', index);
if (lastLoadedPost && lastLoadedPost === post) { if (lastLoadedPost && lastLoadedPost === post) {
postStream.appendMore(); postStream.appendMore();

View File

@ -126,31 +126,11 @@ Discourse.PostStream = Em.Object.extend({
return result; return result;
}.property('userFilters.[]', 'summary'), }.property('userFilters.[]', 'summary'),
/** hasNoFilters: function() {
The text describing the current filters. For display in the pop up at the bottom of the
screen.
@property filterDesc
**/
filterDesc: function() {
var streamFilters = this.get('streamFilters'); var streamFilters = this.get('streamFilters');
return !(streamFilters && ((streamFilters.filter === 'summary') || streamFilters.userFilters));
if (streamFilters.filter && streamFilters.filter === "summary") {
return I18n.t("topic.filters.summary", {
n_summarized_posts: I18n.t("topic.filters.n_summarized_posts", { count: this.get('filteredPostsCount') }),
of_n_posts: I18n.t("topic.filters.of_n_posts", { count: this.get('topic.posts_count') })
});
} else if (streamFilters.username_filters) {
return I18n.t("topic.filters.user", {
n_posts: I18n.t("topic.filters.n_posts", { count: this.get('filteredPostsCount') }),
by_n_users: I18n.t("topic.filters.by_n_users", { count: streamFilters.username_filters.length })
});
}
return "";
}.property('streamFilters.[]', 'topic.posts_count', 'posts.length'), }.property('streamFilters.[]', 'topic.posts_count', 'posts.length'),
hasNoFilters: Em.computed.empty('filterDesc'),
/** /**
Returns the window of posts above the current set in the stream, bound to the top of the stream. Returns the window of posts above the current set in the stream, bound to the top of the stream.
This is the collection we'll ask for when scrolling upwards. This is the collection we'll ask for when scrolling upwards.
@ -274,6 +254,66 @@ Discourse.PostStream = Em.Object.extend({
}, },
hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), hasLoadedData: Em.computed.and('hasPosts', 'hasStream'),
/**
Fill in a gap of posts before a particular post
@method fillGapBefore
@paaram {Discourse.Post} post beside gap
@paaram {Array} gap array of post ids to load
@returns {Ember.Deferred} a promise that's resolved when the posts have been added.
**/
fillGapBefore: function(post, gap) {
var postId = post.get('id'),
stream = this.get('stream'),
idx = stream.indexOf(postId),
currentPosts = this.get('posts'),
self = this;
if (idx !== -1) {
// Insert the gap at the appropriate place
stream.splice.apply(stream, [idx, 0].concat(gap));
stream.enumerableContentDidChange();
var postIdx = currentPosts.indexOf(post);
if (postIdx !== -1) {
return this.findPostsByIds(gap).then(function(posts) {
posts.forEach(function(p) {
var stored = self.storePost(p);
if (!currentPosts.contains(stored)) {
currentPosts.insertAt(postIdx++, stored);
}
});
delete self.get('gaps.before')[postId];
});
}
}
return Ember.RSVP.resolve();
},
/**
Fill in a gap of posts after a particular post
@method fillGapAfter
@paaram {Discourse.Post} post beside gap
@paaram {Array} gap array of post ids to load
@returns {Ember.Deferred} a promise that's resolved when the posts have been added.
**/
fillGapAfter: function(post, gap) {
var postId = post.get('id'),
stream = this.get('stream'),
idx = stream.indexOf(postId),
currentPosts = this.get('posts'),
self = this;
if (idx !== -1) {
stream.pushObjects(gap);
return this.appendMore();
}
return Ember.RSVP.resolve();
},
/** /**
Appends the next window of posts to the stream. Call it when scrolling downwards. Appends the next window of posts to the stream. Call it when scrolling downwards.
@ -522,9 +562,9 @@ Discourse.PostStream = Em.Object.extend({
@method updateFromJson @method updateFromJson
**/ **/
updateFromJson: function(postStreamData) { updateFromJson: function(postStreamData) {
var postStream = this; var postStream = this,
posts = this.get('posts');
var posts = this.get('posts');
posts.clear(); posts.clear();
if (postStreamData) { if (postStreamData) {
// Load posts if present // Load posts if present

View File

@ -1,3 +1,5 @@
{{post-gap post=this postStream=controller.postStream before="true"}}
<div class='row'> <div class='row'>
{{view Discourse.ReplyHistory contentBinding="replyHistory"}} {{view Discourse.ReplyHistory contentBinding="replyHistory"}}
</div> </div>
@ -82,3 +84,5 @@
</div> </div>
</article> </article>
{{post-gap post=this postStream=controller.postStream before="false"}}

View File

@ -52,7 +52,7 @@
<nav id='topic-progress' title="{{i18n topic.progress.title}}" {{bindAttr class="hideProgress:hidden"}}> <nav id='topic-progress' title="{{i18n topic.progress.title}}" {{bindAttr class="hideProgress:hidden"}}>
<button id='jump-top' title="{{i18n topic.progress.jump_top}}" {{bindAttr disabled="jumpTopDisabled"}} {{action jumpTop}}><i class="icon-circle-arrow-up"></i></button> <button id='jump-top' title="{{i18n topic.progress.jump_top}}" {{bindAttr disabled="jumpTopDisabled"}} {{action jumpTop}}><i class="icon-circle-arrow-up"></i></button>
<div class='nums' {{bindAttr title="progressPositionTitle"}}> <div class='nums' {{bindAttr title="progressPositionTitle"}}>
<h4>{{progressPosition}}</h4><span {{bindAttr class="hugeNumberOfPosts:hidden"}}> <span>{{i18n of_value}}</span> <h4>{{highest_post_number}}</h4></span> <h4>{{progressPosition}}</h4><span {{bindAttr class="hugeNumberOfPosts:hidden"}}> <span>{{i18n of_value}}</span> <h4>{{postStream.filteredPostsCount}}</h4></span>
</div> </div>
<button id='jump-bottom' title="{{i18n topic.progress.jump_bottom}}" {{bindAttr disabled="jumpBottomDisabled"}} {{action jumpBottom}}><i class="icon-circle-arrow-down"></i></button> <button id='jump-bottom' title="{{i18n topic.progress.jump_bottom}}" {{bindAttr disabled="jumpBottomDisabled"}} {{action jumpBottom}}><i class="icon-circle-arrow-down"></i></button>
<div class='bg'>&nbsp;</div> <div class='bg'>&nbsp;</div>
@ -121,12 +121,6 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
<div id='topic-filter' {{bindAttr class="postStream.hasNoFilters:hidden"}}>
{{postStream.filterDesc}}
<a href='#' {{action cancelFilter target="postStream"}}>{{i18n topic.filters.cancel}}</a>
</div>
{{render share}} {{render share}}
{{render posterExpansion}} {{render posterExpansion}}

View File

@ -1,6 +1,22 @@
@import "common/foundation/variables"; @import "common/foundation/variables";
@import "common/foundation/mixins"; @import "common/foundation/mixins";
.gap {
background-color: #f9f9f9;
border: 1px solid #eee;
padding: 5px 10px;
margin-bottom: 10px;
color: #555;
cursor: pointer;
&:hover {
background-color: #eee;
}
i.icon {
margin-right: 6px;
}
}
.container { .container {
@extend .clearfix; @extend .clearfix;

View File

@ -0,0 +1,11 @@
class GapSerializer < ApplicationSerializer
attributes :before, :after
def before
@object.before
end
def after
@object.after
end
end

View File

@ -1,3 +1,6 @@
require_dependency 'gap_serializer'
require_dependency 'post_serializer'
module PostStreamSerializerMixin module PostStreamSerializerMixin
def self.included(klass) def self.included(klass)
@ -5,17 +8,18 @@ module PostStreamSerializerMixin
end end
def post_stream def post_stream
{ posts: posts, result = { posts: posts, stream: object.filtered_post_ids }
stream: object.filtered_post_ids } result[:gaps] = GapSerializer.new(object.gaps, root: false) if object.gaps.present?
result
end end
def posts def posts
return @posts if @posts.present? return @posts if @posts.present?
@posts = [] @posts = []
@highest_number_in_posts = 0 highest_number_in_posts = 0
if object.posts if object.posts
object.posts.each_with_index do |p, idx| object.posts.each_with_index do |p, idx|
@highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts highest_number_in_posts = p.post_number if p.post_number > highest_number_in_posts
ps = PostSerializer.new(p, scope: scope, root: false) ps = PostSerializer.new(p, scope: scope, root: false)
ps.topic_slug = object.topic.slug ps.topic_slug = object.topic.slug
ps.topic_view = object ps.topic_view = object

View File

@ -815,6 +815,9 @@ en:
other: "(post withdrawn by author, will be automatically deleted in %{count} hours unless flagged)" other: "(post withdrawn by author, will be automatically deleted in %{count} hours unless flagged)"
deleted_by: "deleted by" deleted_by: "deleted by"
expand_collapse: "expand/collapse" expand_collapse: "expand/collapse"
gap:
one: "1 post was omitted due to your current filter. Click to show it."
other: "{{count}} posts were omitted due to your current filter. Click to show them."
has_replies: has_replies:
one: "Reply" one: "Reply"

54
lib/gaps.rb Normal file
View File

@ -0,0 +1,54 @@
#
# This is used for finding the gaps between a subset of elements in an array
# and the original layout. We use this in Discourse to find gaps between posts.
#
# Note that we will only return a gap as 'before' or 'after', not both. We only
# want to display the gap once.
#
class Gaps
attr_reader :before, :after
def initialize(subset, original)
@before = {}
@after = {}
@subset = subset
@original = original
find_gaps
end
def empty?
@before.size == 0 && @after.size == 0
end
def find_gaps
return if @subset.nil? or @original.nil?
i = j = 0
gaps = {}
current_gap = []
while
e1 = @subset[i]
e2 = @original[j]
if (e1 == e2)
if current_gap.size > 0
@before[e1] = current_gap.dup
current_gap = []
end
i = i + 1
else
current_gap << e2
end
j = j + 1
break if (i == @subset.size) || (j == @original.size)
end
@after[@subset[i-1]] = @original[j..-1] if j < @original.size
end
end

View File

@ -2,6 +2,7 @@ require_dependency 'guardian'
require_dependency 'topic_query' require_dependency 'topic_query'
require_dependency 'filter_best_posts' require_dependency 'filter_best_posts'
require_dependency 'summarize' require_dependency 'summarize'
require_dependency 'gaps'
class TopicView class TopicView
@ -44,6 +45,15 @@ class TopicView
path path
end end
def contains_gaps?
@contains_gaps
end
def gaps
return unless @contains_gaps
Gaps.new(filtered_post_ids, unfiltered_posts.order(:sort_order).pluck(:id))
end
def last_post def last_post
return nil if @posts.blank? return nil if @posts.blank?
@last_post ||= @posts.last @last_post ||= @posts.last
@ -113,9 +123,7 @@ class TopicView
# Filter to all posts near a particular post number # Filter to all posts near a particular post number
def filter_posts_near(post_number) def filter_posts_near(post_number)
min_idx, max_idx = get_minmax_ids(post_number) min_idx, max_idx = get_minmax_ids(post_number)
filter_posts_in_range(min_idx, max_idx) filter_posts_in_range(min_idx, max_idx)
end end
@ -255,14 +263,36 @@ class TopicView
finder.first finder.first
end end
def unfiltered_posts
result = @topic.posts.where(hidden: false)
result = result.with_deleted if @user.try(:staff?)
result
end
def setup_filtered_posts def setup_filtered_posts
@filtered_posts = @topic.posts.where(hidden: false)
# Certain filters might leave gaps between posts. If that's true, we can return a gap structure
@contains_gaps = false
@filtered_posts = unfiltered_posts
@filtered_posts = @filtered_posts.with_deleted if @user.try(:staff?) @filtered_posts = @filtered_posts.with_deleted if @user.try(:staff?)
@filtered_posts = @filtered_posts.summary if @filter == 'summary'
@filtered_posts = @filtered_posts.where('posts.post_type <> ?', Post.types[:moderator_action]) if @best.present? # Filters
return unless @username_filters.present? if @filter == 'summary'
usernames = @username_filters.map{|u| u.downcase} @filtered_posts = @filtered_posts.summary
@filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames) @contains_gaps = true
end
if @best.present?
@filtered_posts = @filtered_posts.where('posts.post_type <> ?', Post.types[:moderator_action])
@contains_gaps = true
end
if @username_filters.present?
usernames = @username_filters.map{|u| u.downcase}
@filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames)
@contains_gaps = true
end
end end
def check_and_raise_exceptions def check_and_raise_exceptions

View File

@ -0,0 +1,81 @@
require 'spec_helper'
require 'cache'
describe Gaps do
it 'returns no gaps for empty data' do
Gaps.new(nil, nil).should be_blank
end
it 'returns no gaps with one element' do
Gaps.new([1], [1]).should be_blank
end
it 'returns no gaps when all elements are present' do
Gaps.new([1,2,3], [1,2,3]).should be_blank
end
context "single element gap" do
let(:gap) { Gaps.new([1,3], [1,2,3]) }
it 'has a gap for post 3' do
gap.should_not be_blank
gap.before[3].should == [2]
gap.after.should be_blank
end
end
context "larger gap" do
let(:gap) { Gaps.new([1,2,3,6,7], [1,2,3,4,5,6,7]) }
it 'has a gap for post 6' do
gap.should_not be_blank
gap.before[6].should == [4,5]
gap.after.should be_blank
end
end
context "multiple gaps" do
let(:gap) { Gaps.new([1,5,6,7,10], [1,2,3,4,5,6,7,8,9,10]) }
it 'has both gaps' do
gap.should_not be_blank
gap.before[5].should == [2,3,4]
gap.before[10].should == [8,9]
gap.after.should be_blank
end
end
context "a gap in the beginning" do
let(:gap) { Gaps.new([2,3,4], [1,2,3,4]) }
it 'has the gap' do
gap.should_not be_blank
gap.before[2].should == [1]
gap.after.should be_blank
end
end
context "a gap in the ending" do
let(:gap) { Gaps.new([1,2,3], [1,2,3,4]) }
it 'has the gap' do
gap.should_not be_blank
gap.before.should be_blank
gap.after[3].should == [4]
end
end
context "a large gap in the ending" do
let(:gap) { Gaps.new([1,2,3], [1,2,3,4,5,6]) }
it 'has the gap' do
gap.should_not be_blank
gap.before.should be_blank
gap.after[3].should == [4,5,6]
end
end
end

View File

@ -89,7 +89,6 @@ describe TopicView do
end end
it "raises NotLoggedIn if the user isn't logged in and is trying to view a private message" do it "raises NotLoggedIn if the user isn't logged in and is trying to view a private message" do
Topic.any_instance.expects(:private_message?).returns(true) Topic.any_instance.expects(:private_message?).returns(true)
lambda { TopicView.new(topic.id, nil) }.should raise_error(Discourse::NotLoggedIn) lambda { TopicView.new(topic.id, nil) }.should raise_error(Discourse::NotLoggedIn)
@ -233,6 +232,26 @@ describe TopicView do
p6.save! p6.save!
end end
describe "contains_gaps?" do
it "does not contain contains_gaps with default filtering" do
topic_view.contains_gaps?.should be_false
end
it "contains contains_gaps when filtered by username" do
TopicView.new(topic.id, coding_horror, username_filters: ['eviltrout']).contains_gaps?.should be_true
end
it "contains contains_gaps when filtered by summary" do
TopicView.new(topic.id, coding_horror, filter: 'summary').contains_gaps?.should be_true
end
it "contains contains_gaps when filtered by best" do
TopicView.new(topic.id, coding_horror, best: 5).contains_gaps?.should be_true
end
end
describe '#filter_posts_paged' do describe '#filter_posts_paged' do
before { SiteSetting.stubs(:posts_per_page).returns(2) } before { SiteSetting.stubs(:posts_per_page).returns(2) }

View File

@ -141,12 +141,10 @@ test("streamFilters", function() {
deepEqual(postStream.get('streamFilters'), {}, "there are no postFilters by default"); deepEqual(postStream.get('streamFilters'), {}, "there are no postFilters by default");
ok(postStream.get('hasNoFilters'), "there are no filters by default"); ok(postStream.get('hasNoFilters'), "there are no filters by default");
blank(postStream.get("filterDesc"), "there is no description of the filter");
postStream.set('summary', true); postStream.set('summary', true);
deepEqual(postStream.get('streamFilters'), {filter: "summary"}, "postFilters contains the summary flag"); deepEqual(postStream.get('streamFilters'), {filter: "summary"}, "postFilters contains the summary flag");
ok(!postStream.get('hasNoFilters'), "now there are filters present"); ok(!postStream.get('hasNoFilters'), "now there are filters present");
present(postStream.get("filterDesc"), "there is a description of the filter");
postStream.toggleParticipant(participant.username); postStream.toggleParticipant(participant.username);
deepEqual(postStream.get('streamFilters'), { deepEqual(postStream.get('streamFilters'), {