Commit 4e1c5954 by Greg Price

Merge pull request #2377 from edx/gprice/forum-thread-pagination

Add pagination of responses to forum threads
parents 830ad942 062025ee
describe "DiscussionThreadView", ->
beforeEach ->
setFixtures(
"""
<script type="text/template" id="thread-template">
<article class="discussion-article">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</article>
</script>
<div class="thread-fixture"/>
"""
)
jasmine.Clock.useMock()
@threadData = {
id: "dummy"
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadView({ model: @thread })
@view.setElement($(".thread-fixture"))
spyOn($, "ajax")
# Avoid unnecessary boilerplate
spyOn(@view.showView, "render")
spyOn(@view, "makeWmdEditor")
spyOn(DiscussionThreadView.prototype, "renderResponse")
describe "response count and pagination", ->
setNextResponseContent = (content) ->
$.ajax.andCallFake(
(params) =>
params.success({"content": content})
{always: ->}
)
renderWithContent = (view, content) ->
setNextResponseContent(content)
view.render()
jasmine.Clock.tick(100)
assertRenderedCorrectly = (view, countText, displayCountText, buttonText) ->
expect(view.$el.find(".response-count").text()).toEqual(countText)
if displayCountText
expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText)
else
expect(view.$el.find(".response-display-count").length).toEqual(0)
if buttonText
expect(view.$el.find(".load-response-button").text()).toEqual(buttonText)
else
expect(view.$el.find(".load-response-button").length).toEqual(0)
it "correctly render for a thread with no responses", ->
renderWithContent(@view, {resp_total: 0, children: []})
assertRenderedCorrectly(@view, "0 responses", null, null)
it "correctly render for a thread with one response", ->
renderWithContent(@view, {resp_total: 1, children: [{}]})
assertRenderedCorrectly(@view, "1 response", "Showing all responses", null)
it "correctly render for a thread with one additional page", ->
renderWithContent(@view, {resp_total: 2, children: [{}]})
assertRenderedCorrectly(@view, "2 responses", "Showing first response", "Load all responses")
it "correctly render for a thread with multiple additional pages", ->
renderWithContent(@view, {resp_total: 111, children: [{}, {}]})
assertRenderedCorrectly(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
describe "on clicking the load more button", ->
beforeEach ->
renderWithContent(@view, {resp_total: 5, children: [{}]})
assertRenderedCorrectly(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "5 responses", "Showing all responses", null)
it "correctly re-render when one page remains", ->
setNextResponseContent({resp_total: 42, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "42 responses", "Showing first 3 responses", "Load all responses")
it "correctly re-render when multiple pages remain", ->
setNextResponseContent({resp_total: 111, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
if Backbone?
class @DiscussionThreadView extends DiscussionContentView
INITIAL_RESPONSE_PAGE_SIZE = 25
SUBSEQUENT_RESPONSE_PAGE_SIZE = 100
events:
"click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse"
......@@ -11,6 +14,7 @@ if Backbone?
initialize: ->
super()
@createShowView()
@responses = new Comments()
renderTemplate: ->
@template = _.template($("#thread-template").html())
......@@ -18,7 +22,6 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@$el.find(".loading").hide()
@delegateEvents()
@renderShowView()
......@@ -27,26 +30,95 @@ if Backbone?
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@renderResponses()
@responses.on("add", @renderResponse)
# Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error
setTimeout(
=> @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true),
100
)
@
cleanup: ->
if @responsesRequest?
@responsesRequest.abort()
renderResponses: ->
setTimeout(=>
@$el.find(".loading").show()
, 200)
loadResponses: (responseLimit, elem, firstLoad) ->
@responsesRequest = DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor('retrieve_single_thread', @model.get('commentable_id'), @model.id)
data:
resp_skip: @responses.size()
resp_limit: responseLimit if responseLimit
$elem: elem
$loading: elem
takeFocus: true
complete: =>
@responseRequest = null
success: (data, textStatus, xhr) =>
@responsesRequest = null
@$el.find(".loading").remove()
Content.loadContentInfos(data['annotated_content_info'])
comments = new Comments(data['content']['children'])
comments.each @renderResponse
@responses.add(data['content']['children'])
@renderResponseCountAndPagination(data['content']['resp_total'])
@trigger "thread:responses:rendered"
error: =>
if firstLoad
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading responses. Please reload the page.")
)
else
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading more responses. Please try again.")
)
renderResponseCountAndPagination: (responseTotal) =>
@$el.find(".response-count").html(
interpolate(
ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
),
{numResponses: responseTotal},
true
)
)
responsePagination = @$el.find(".response-pagination")
responsePagination.empty()
if responseTotal > 0
responsesRemaining = responseTotal - @responses.size()
showingResponsesText =
if responsesRemaining == 0
gettext("Showing all responses")
else
interpolate(
ngettext(
"Showing first response",
"Showing first %(numResponses)s responses",
@responses.size()
),
{numResponses: @responses.size()},
true
)
responsePagination.append($("<span>").addClass("response-display-count").html(
_.escape(showingResponsesText)
))
if responsesRemaining > 0
if responsesRemaining < SUBSEQUENT_RESPONSE_PAGE_SIZE
responseLimit = null
buttonText = gettext("Load all responses")
else
responseLimit = SUBSEQUENT_RESPONSE_PAGE_SIZE
buttonText = interpolate(
gettext("Load next %(numResponses)s responses"),
{numResponses: responseLimit},
true
)
loadMoreButton = $("<button>").addClass("load-response-button").html(
_.escape(buttonText)
)
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton)
renderResponse: (response) =>
response.set('thread', @model)
......
......@@ -11,7 +11,7 @@ from django_comment_client.forum import views
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from nose.tools import assert_true # pylint: disable=E0611
from mock import patch, Mock
from mock import patch, Mock, ANY
import logging
......@@ -85,6 +85,26 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase):
self.assertEqual(self.response.status_code, 404)
def make_mock_thread_data(text, thread_id, include_children):
thread_data = {
"id": thread_id,
"type": "thread",
"title": text,
"body": text,
"commentable_id": "dummy_commentable_id",
"resp_total": 42,
"resp_skip": 25,
"resp_limit": 5,
}
if include_children:
thread_data["children"] = [{
"id": "dummy_comment_id",
"type": "comment",
"body": text,
}]
return thread_data
def make_mock_request_impl(text, thread_id=None):
def mock_request_impl(*args, **kwargs):
url = args[1]
......@@ -92,30 +112,13 @@ def make_mock_request_impl(text, thread_id=None):
return Mock(
status_code=200,
text=json.dumps({
"collection": [{
"id": "dummy_thread_id",
"type": "thread",
"commentable_id": "dummy_commentable_id",
"title": text,
"body": text,
}]
"collection": [make_mock_thread_data(text, "dummy_thread_id", False)]
})
)
elif thread_id and url.endswith(thread_id):
return Mock(
status_code=200,
text=json.dumps({
"id": thread_id,
"type": "thread",
"title": text,
"body": text,
"commentable_id": "dummy_commentable_id",
"children": [{
"id": "dummy_comment_id",
"type": "comment",
"body": text,
}],
})
text=json.dumps(make_mock_thread_data(text, thread_id, True))
)
else: # user query
return Mock(
......@@ -129,6 +132,116 @@ def make_mock_request_impl(text, thread_id=None):
return mock_request_impl
class StringEndsWithMatcher(object):
def __init__(self, suffix):
self.suffix = suffix
def __eq__(self, other):
return other.endswith(self.suffix)
class PartialDictMatcher(object):
def __init__(self, expected_values):
self.expected_values = expected_values
def __eq__(self, other):
return all([
key in other and other[key] == value
for key, value in self.expected_values.iteritems()
])
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
@patch('requests.request')
class SingleThreadTestCase(ModuleStoreTestCase):
def setUp(self):
self.course = CourseFactory.create()
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
def test_ajax(self, mock_request):
text = "dummy content"
thread_id = "test_thread_id"
mock_request.side_effect = make_mock_request_impl(text, thread_id)
request = RequestFactory().get(
"dummy_url",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
request.user = self.student
response = views.single_thread(
request,
self.course.id,
"dummy_discussion_id",
"test_thread_id"
)
self.assertEquals(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEquals(
response_data["content"],
make_mock_thread_data(text, thread_id, True)
)
mock_request.assert_called_with(
"get",
StringEndsWithMatcher(thread_id), # url
data=None,
params=PartialDictMatcher({"mark_as_read": True, "user_id": 1, "recursive": True}),
headers=ANY,
timeout=ANY
)
def test_skip_limit(self, mock_request):
text = "dummy content"
thread_id = "test_thread_id"
response_skip = "45"
response_limit = "15"
mock_request.side_effect = make_mock_request_impl(text, thread_id)
request = RequestFactory().get(
"dummy_url",
{"resp_skip": response_skip, "resp_limit": response_limit},
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
request.user = self.student
response = views.single_thread(
request,
self.course.id,
"dummy_discussion_id",
"test_thread_id"
)
self.assertEquals(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEquals(
response_data["content"],
make_mock_thread_data(text, thread_id, True)
)
mock_request.assert_called_with(
"get",
StringEndsWithMatcher(thread_id), # url
data=None,
params=PartialDictMatcher({
"mark_as_read": True,
"user_id": 1,
"recursive": True,
"resp_skip": response_skip,
"resp_limit": response_limit,
}),
headers=ANY,
timeout=ANY
)
def test_post(self, mock_request):
request = RequestFactory().post("dummy_url")
response = views.single_thread(
request,
self.course.id,
"dummy_discussion_id",
"dummy_thread_id"
)
self.assertEquals(response.status_code, 405)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class InlineDiscussionUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin):
def setUp(self):
......
......@@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.core.context_processors import csrf
from django.contrib.auth.models import User
from django.views.decorators.http import require_GET
import newrelic.agent
from edxmako.shortcuts import render_to_response
......@@ -229,6 +230,7 @@ def forum_form_discussion(request, course_id):
return render_to_response('discussion/index.html', context)
@require_GET
@login_required
def single_thread(request, course_id, discussion_id, thread_id):
nr_transaction = newrelic.agent.current_transaction()
......@@ -237,12 +239,16 @@ def single_thread(request, course_id, discussion_id, thread_id):
cc_user = cc.User.from_django_user(request.user)
user_info = cc_user.to_dict()
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
thread = cc.Thread.find(thread_id).retrieve(
recursive=True,
user_id=request.user.id,
response_skip=request.GET.get("resp_skip"),
response_limit=request.GET.get("resp_limit")
)
if request.is_ajax():
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id}
content = utils.safe_content(thread.to_dict())
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
add_courseware_context([content], course)
......
......@@ -361,7 +361,7 @@ def safe_content(content):
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'unread_comments_count',
'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers',
'stats'
'stats', 'resp_skip', 'resp_limit', 'resp_total',
]
......
......@@ -74,10 +74,9 @@ class Thread(models.Model):
'recursive': kwargs.get('recursive'),
'user_id': kwargs.get('user_id'),
'mark_as_read': kwargs.get('mark_as_read', True),
'resp_skip': kwargs.get('response_skip'),
'resp_limit': kwargs.get('response_limit'),
}
# user_id may be none, in which case it shouldn't be part of the
# request.
request_params = strip_none(request_params)
response = perform_request('get', url, request_params)
......
......@@ -1406,7 +1406,8 @@ body.discussion {
}
.discussion-post {
padding: $baseline*2 $baseline*2 $baseline/2 $baseline*2;
padding: $baseline*2 $baseline*2 $baseline $baseline*2;
box-shadow: 0 1px 3px $shadow;
> header .vote-btn {
position: relative;
......@@ -1813,7 +1814,7 @@ body.discussion {
.discussion-reply-new {
padding: 0px 30px $baseline;
padding: 0.5*$baseline 30px $baseline;
@include clearfix;
@include transition(opacity .2s linear 0s);
......@@ -2572,3 +2573,32 @@ display:none;
color: #333;
font-style: italic;
}
.response-count {
margin-top: $baseline;
padding: 0px 3*$baseline;
}
.response-pagination {
padding: 0px 1.5*$baseline;
.response-display-count {
display: block;
padding: 0.5*$baseline 1.5*$baseline;
}
.load-response-button {
display: block;
@include white-button;
font: normal 1em/1.6em $sans-serif;
position: relative;
padding: 0px 1.5*$baseline;
margin: $baseline/2 0px;
border: 1px solid #b2b2b2;
box-shadow: 0 1px 3px rgba(0, 0, 0, .15);
font-size: 13px;
text-align: left;
@include animation(fadeIn .3s);
width: 100%;
}
}
......@@ -5,15 +5,15 @@
<script type="text/template" id="thread-template">
<article class="discussion-article" data-id="${'<%- id %>'}">
<div class="thread-content-wrapper"></div>
<div class="response-count"/>
<div class="add-response">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
</div>
<ol class="responses">
<li class="loading"><div class="loading-animation"><span class="sr">${_('Loading content')}</span></div></li>
</ol>
<ol class="responses"/>
<div class="response-pagination"/>
<div class="post-status-closed bottom-post-status" style="display: none">
${_("This thread is closed.")}
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment