Commit 8c8367b0 by Greg Price

Merge pull request #3183 from edx/gprice/inline-response-pagination

Add repsonse pagination to inline discussions
parents 8609345b 16847d85
......@@ -14,6 +14,7 @@ class StubCommentsServiceHandler(StubHttpRequestHandler):
"/api/v1/threads$": self.do_threads,
"/api/v1/threads/(?P<thread_id>\\w+)$": self.do_thread,
"/api/v1/comments/(?P<comment_id>\\w+)$": self.do_comment,
"/api/v1/(?P<commentable_id>\\w+)/threads$": self.do_commentable,
}
path = urlparse.urlparse(self.path).path
for pattern in pattern_handlers:
......@@ -63,6 +64,17 @@ class StubCommentsServiceHandler(StubHttpRequestHandler):
comment = self.server.config['comments'][comment_id]
self.send_json_response(comment)
def do_commentable(self, commentable_id):
self.send_json_response({
"collection": [
thread
for thread in self.server.config.get('threads', {}).values()
if thread.get('commentable_id') == commentable_id
],
"page": 1,
"num_pages": 1,
})
class StubCommentsService(StubHttpService):
HANDLER_CLASS = StubCommentsServiceHandler
describe "DiscussionThreadInlineView", ->
beforeEach ->
setFixtures(
"""
<script type="text/template" id="_inline_thread">
<article class="discussion-article">
<div class="non-cohorted-indicator"/>
<div class="post-extended-content">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="expand-post">Expand</a>
<a href="javascript:void(0)" class="collapse-post">Collapse</a>
</div>
</article>
</script>
<script type="text/template" id="_inline_thread_cohorted">
<article class="discussion-article">
<div class="cohorted-indicator"/>
<div class="post-extended-content">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="expand-post">Expand</a>
<a href="javascript:void(0)" class="collapse-post">Collapse</a>
</div>
</article>
</script>
<div class="thread-fixture"/>
"""
)
@threadData = {
id: "dummy",
body: "dummy body",
abuse_flaggers: [],
votes: {up_count: "42"}
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadInlineView({ model: @thread })
@view.setElement($(".thread-fixture"))
spyOn($, "ajax")
# Avoid unnecessary boilerplate
spyOn(@view.showView, "render")
spyOn(@view, "makeWmdEditor")
spyOn(DiscussionThreadView.prototype, "renderResponse")
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toEqual(1)
expect(content.is(":visible")).toEqual(visible)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".expand-post", not expanded)
assertContentVisible(view, ".collapse-post", expanded)
describe "render", ->
it "uses the cohorted template if cohorted", ->
@view.model.set({group_id: 1})
@view.render()
expect(@view.$el.find(".cohorted-indicator").length).toEqual(1)
it "uses the non-cohorted template if not cohorted", ->
@view.render()
expect(@view.$el.find(".non-cohorted-indicator").length).toEqual(1)
it "shows content that should be visible when collapsed", ->
@view.render()
assertExpandedContentVisible(@view, false)
it "does not render any responses by default", ->
@view.render()
expect($.ajax).not.toHaveBeenCalled()
expect(@view.$el.find(".responses li").length).toEqual(0)
describe "expand/collapse", ->
it "shows/hides appropriate content", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expandPost()
assertExpandedContentVisible(@view, true)
@view.collapsePost()
assertExpandedContentVisible(@view, false)
......@@ -27,16 +27,8 @@ describe "DiscussionThreadView", ->
spyOn(DiscussionThreadView.prototype, "renderResponse")
describe "response count and pagination", ->
setNextResponseContent = (content) ->
$.ajax.andCallFake(
(params) =>
params.success({"content": content})
{always: ->}
)
renderWithContent = (view, content) ->
setNextResponseContent(content)
DiscussionViewSpecHelper.setNextResponseContent(content)
view.render()
jasmine.Clock.tick(100)
......@@ -73,16 +65,16 @@ describe "DiscussionThreadView", ->
assertRenderedCorrectly(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]})
DiscussionViewSpecHelper.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: [{}, {}]})
DiscussionViewSpecHelper.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: [{}, {}]})
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 111, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
......@@ -110,6 +110,12 @@ class @DiscussionViewSpecHelper
button.trigger($.Event("keydown", {which: 32}))
expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".vote-btn")
@setNextResponseContent = (content) ->
$.ajax.andCallFake(
(params) =>
params.success({"content": content})
{always: ->}
)
......@@ -22,6 +22,7 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@initLocal()
@delegateEvents()
@renderShowView()
......@@ -33,10 +34,7 @@ if Backbone?
@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
)
setTimeout((=> @loadInitialResponses()), 100)
@
cleanup: ->
......@@ -71,6 +69,9 @@ if Backbone?
gettext("We had some trouble loading more responses. Please try again.")
)
loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true)
renderResponseCountAndPagination: (responseTotal) =>
@$el.find(".response-count").html(
interpolate(
......@@ -226,6 +227,9 @@ if Backbone?
renderEditView: () ->
@renderSubView(@editView)
getShowViewClass: () ->
return DiscussionThreadShowView
createShowView: () ->
if @editView?
......@@ -233,7 +237,8 @@ if Backbone?
@editView.$el.empty()
@editView = null
@showView = new DiscussionThreadShowView(model: @model)
showViewClass = @getShowViewClass()
@showView = new showViewClass(model: @model)
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit
......
......@@ -16,7 +16,7 @@ if Backbone?
@$local = @$el
@$delegateElement = @$local
render: ->
renderTemplate: () ->
if @model.has('group_id')
@template = DiscussionUtil.getTemplate("_inline_thread_cohorted")
else
......@@ -25,107 +25,43 @@ if Backbone?
if not @model.has('abbreviatedBody')
@abbreviateBody()
params = @model.toJSON()
@$el.html(Mustache.render(@template, params))
#@createShowView()
Mustache.render(@template, params)
@initLocal()
@delegateEvents()
@renderShowView()
@renderAttrs()
@$("span.timeago").timeago()
render: () ->
super()
@$el.find('.post-extended-content').hide()
if @expanded
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@renderResponses()
@
createShowView: () ->
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new DiscussionThreadInlineShowView(model: @model)
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit
renderResponses: ->
#TODO: threadview
DiscussionUtil.safeAjax
url: "/courses/#{$$course_id}/discussion/forum/#{@model.get('commentable_id')}/threads/#{@model.id}"
$loading: @$el
success: (data, textStatus, xhr) =>
# @$el.find(".loading").remove()
Content.loadContentInfos(data['annotated_content_info'])
comments = new Comments(data['content']['children'])
comments.each @renderResponse
@trigger "thread:responses:rendered"
@$('.loading').remove()
@$el.find('.collapse-post').hide()
getShowViewClass: () ->
return DiscussionThreadInlineShowView
toggleClosed: (event) ->
#TODO: showview
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
toggleEndorse: (event) ->
#TODO: showview
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
loadInitialResponses: () ->
if @expanded
super()
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140
@model.set('abbreviatedBody', abbreviated)
expandPost: (event) =>
@expanded = true
@$el.addClass('expanded')
@$el.find('.post-body').html(@model.get('body'))
@showView.convertMath()
@$el.find('.expand-post').css('display', 'none')
@$el.find('.collapse-post').css('display', 'block')
@$el.find('.post-extended-content').show()
@makeWmdEditor "reply-body"
@renderAttrs()
if @$el.find('.loading').length
@renderAddResponseButton()
@renderResponses()
if not @expanded
@expanded = true
@loadInitialResponses()
collapsePost: (event) ->
curScroll = $(window).scrollTop()
postTop = @$el.offset().top
if postTop < curScroll
$('html, body').animate({scrollTop: postTop})
@expanded = false
@$el.removeClass('expanded')
@$el.find('.post-body').html(@model.get('abbreviatedBody'))
@showView.convertMath()
@$el.find('.expand-post').css('display', 'block')
@$el.find('.collapse-post').css('display', 'none')
@$el.find('.post-extended-content').hide()
@$el.find('.expand-post').css('display', 'block')
createEditView: () ->
super()
@editView.bind "thread:update", @expandPost
@editView.bind "thread:update", @abbreviateBody
@editView.bind "thread:cancel_edit", @expandPost
......@@ -31,6 +31,7 @@ lib_paths:
- js/vendor/jquery.min.js
- js/vendor/jasmine-jquery.js
- js/vendor/jasmine-imagediff.js
- js/vendor/mustache.js
- js/vendor/underscore-min.js
- js/vendor/backbone-min.js
- js/vendor/jquery.timeago.js
......
"""
Courseware page.
"""
from .course_page import CoursePage
class CoursewarePage(CoursePage):
"""
Course info.
"""
url_path = "courseware"
def is_browser_on_page(self):
return self.q(css='body.courseware').present
from bok_choy.page_object import unguarded
from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
class DiscussionSingleThreadPage(CoursePage):
def __init__(self, browser, course_id, thread_id):
super(DiscussionSingleThreadPage, self).__init__(browser, course_id)
self.thread_id = thread_id
class DiscussionThreadPage(PageObject):
url = None
def is_browser_on_page(self):
return self.q(
css="body.discussion .discussion-article[data-id='{thread_id}']".format(thread_id=self.thread_id)
).present
def __init__(self, browser, thread_selector):
super(DiscussionThreadPage, self).__init__(browser)
self.thread_selector = thread_selector
def _find_within(self, selector):
"""
Returns a query corresponding to the given CSS selector within the scope
of this thread page
"""
return self.q(css=self.thread_selector + " " + selector)
@property
@unguarded
def url_path(self):
return "discussion/forum/dummy/threads/" + self.thread_id
def is_browser_on_page(self):
return self.q(css=self.thread_selector).present
def _get_element_text(self, selector):
"""
Returns the text of the first element matching the given selector, or
None if no such element exists
"""
text_list = self.q(css=selector).text
text_list = self._find_within(selector).text
return text_list[0] if text_list else None
def _is_element_visible(self, selector):
query = self._find_within(selector)
return query.present and query.visible
def get_response_total_text(self):
"""Returns the response count text, or None if not present"""
return self._get_element_text(".response-count")
def get_num_displayed_responses(self):
"""Returns the number of responses actually rendered"""
return len(self.q(css=".discussion-response").results)
return len(self._find_within(".discussion-response"))
def get_shown_responses_text(self):
"""Returns the shown response count text, or None if not present"""
......@@ -45,7 +51,7 @@ class DiscussionSingleThreadPage(CoursePage):
def load_more_responses(self):
"""Clicks the load more responses button and waits for responses to load"""
self.q(css=".load-response-button").first.click()
self._find_within(".load-response-button").click()
def _is_ajax_finished():
return self.browser.execute_script("return jQuery.active") == 0
......@@ -64,25 +70,19 @@ class DiscussionSingleThreadPage(CoursePage):
Clicks the add response button and ensures that the response text
field receives focus
"""
self.q(css=".add-response-btn").first.click()
self._find_within(".add-response-btn").first.click()
EmptyPromise(
lambda: self.q(css="#wmd-input-reply-body-{thread_id}:focus".format(thread_id=self.thread_id)),
lambda: self._find_within(".discussion-reply-new textarea:focus").present,
"Response field received focus"
).fulfill()
def _is_element_visible(self, selector):
return (
self.q(css=selector).present and
self.q(css=selector).visible
)
def is_response_editor_visible(self, response_id):
"""Returns true if the response editor is present, false otherwise"""
return self._is_element_visible(".response_{} .edit-post-body".format(response_id))
def start_response_edit(self, response_id):
"""Click the edit button for the response, loading the editing view"""
self.q(css=".response_{} .discussion-response .action-edit".format(response_id)).first.click()
self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click()
EmptyPromise(
lambda: self.is_response_editor_visible(response_id),
"Response edit started"
......@@ -105,7 +105,7 @@ class DiscussionSingleThreadPage(CoursePage):
def delete_comment(self, comment_id):
with self.handle_alert():
self.q(css="#comment_{} div.action-delete".format(comment_id)).first.click()
self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click()
EmptyPromise(
lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed"
......@@ -120,12 +120,12 @@ class DiscussionSingleThreadPage(CoursePage):
return self._is_element_visible(".edit-comment-body[data-id='{}']".format(comment_id))
def _get_comment_editor_value(self, comment_id):
return self.q(css="#wmd-input-edit-comment-body-{}".format(comment_id)).text[0]
return self._find_within("#wmd-input-edit-comment-body-{}".format(comment_id)).text[0]
def start_comment_edit(self, comment_id):
"""Click the edit button for the comment, loading the editing view"""
old_body = self.get_comment_body(comment_id)
self.q(css="#comment_{} .action-edit".format(comment_id)).first.click()
self._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
EmptyPromise(
lambda: (
self.is_comment_editor_visible(comment_id) and
......@@ -137,11 +137,11 @@ class DiscussionSingleThreadPage(CoursePage):
def set_comment_editor_value(self, comment_id, new_body):
"""Replace the contents of the comment editor"""
self.q(css="#comment_{} .wmd-input".format(comment_id)).fill(new_body)
self._find_within("#comment_{} .wmd-input".format(comment_id)).fill(new_body)
def submit_comment_edit(self, comment_id, new_comment_body):
"""Click the submit button on the comment editor"""
self.q(css="#comment_{} .post-update".format(comment_id)).first.click()
self._find_within("#comment_{} .post-update".format(comment_id)).first.click()
EmptyPromise(
lambda: (
not self.is_comment_editor_visible(comment_id) and
......@@ -153,7 +153,7 @@ class DiscussionSingleThreadPage(CoursePage):
def cancel_comment_edit(self, comment_id, original_body):
"""Click the cancel button on the comment editor"""
self.q(css="#comment_{} .post-cancel".format(comment_id)).first.click()
self._find_within("#comment_{} .post-cancel".format(comment_id)).first.click()
EmptyPromise(
lambda: (
not self.is_comment_editor_visible(comment_id) and
......@@ -162,3 +162,72 @@ class DiscussionSingleThreadPage(CoursePage):
),
"Comment edit was canceled"
).fulfill()
class DiscussionTabSingleThreadPage(CoursePage):
def __init__(self, browser, course_id, thread_id):
super(DiscussionTabSingleThreadPage, self).__init__(browser, course_id)
self.thread_page = DiscussionThreadPage(
browser,
"body.discussion .discussion-article[data-id='{thread_id}']".format(thread_id=thread_id)
)
self.url_path = "discussion/forum/dummy/threads/" + thread_id
def is_browser_on_page(self):
return self.thread_page.is_browser_on_page()
def __getattr__(self, name):
return getattr(self.thread_page, name)
class InlineDiscussionPage(PageObject):
url = None
def __init__(self, browser, discussion_id):
super(InlineDiscussionPage, self).__init__(browser)
self._discussion_selector = (
"body.courseware .discussion-module[data-discussion-id='{discussion_id}'] ".format(
discussion_id=discussion_id
)
)
def _find_within(self, selector):
"""
Returns a query corresponding to the given CSS selector within the scope
of this discussion page
"""
return self.q(css=self._discussion_selector + " " + selector)
def is_browser_on_page(self):
return self.q(css=self._discussion_selector).present
def is_discussion_expanded(self):
return self._find_within(".discussion").present
def expand_discussion(self):
"""Click the link to expand the discussion"""
self._find_within(".discussion-show").first.click()
EmptyPromise(
self.is_discussion_expanded,
"Discussion expanded"
).fulfill()
def get_num_displayed_threads(self):
return len(self._find_within(".discussion-thread"))
class InlineDiscussionThreadPage(DiscussionThreadPage):
def __init__(self, browser, thread_id):
super(InlineDiscussionThreadPage, self).__init__(
browser,
"body.courseware .discussion-module #thread_{thread_id}".format(thread_id=thread_id)
)
def expand(self):
"""Clicks the link to expand the thread"""
self._find_within(".expand-post").first.click()
EmptyPromise(
lambda: bool(self.get_response_total_text()),
"Thread expanded"
).fulfill()
......@@ -2069,10 +2069,6 @@ body.discussion {
font-size: 12px;
line-height: 30px;
&.collapse-post {
display: none;
}
.icon {
color: $link-color;
margin-right: ($baseline*0.25);
......
......@@ -3,23 +3,25 @@
<article class="discussion-article" data-id="{{id}}">
<div class="thread-wrapper">
<div class="thread-content-wrapper"></div>
<div class="add-response post-extended-content">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
<div class="post-extended-content">
<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"/>
<div class="response-pagination"/>
<form class="local discussion-reply-new" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
</div>
<ol class="responses post-extended-content">
<li class="loading"><div class="loading-animation"><span class="sr">${_("Loading content")}</span></div></li>
</ol>
<form class="local discussion-reply-new post-extended-content" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
</div>
<div class="local post-tools">
......
......@@ -4,23 +4,25 @@
<div class="thread-wrapper">
<div class="group-visibility-label">{{group_string}}</div>
<div class="thread-content-wrapper"></div>
<div class="add-response post-extended-content">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
<div class="post-extended-content">
<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"/>
<div class="response-pagination"/>
<form class="local discussion-reply-new" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
</div>
<ol class="responses post-extended-content">
<li class="loading"><div class="loading-animation"><span class="sr">${_("Loading content")}</span></div></li>
</ol>
<form class="local discussion-reply-new post-extended-content" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
</div>
<div class="local post-tools">
......
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