Merge pull request #3183 from edx/gprice/inline-response-pagination
Add repsonse pagination to inline discussions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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()
|
||||
@$el.find('.collapse-post').hide()
|
||||
|
||||
getShowViewClass: () ->
|
||||
return DiscussionThreadInlineShowView
|
||||
|
||||
loadInitialResponses: () ->
|
||||
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()
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
16
common/test/acceptance/pages/lms/courseware.py
Normal file
16
common/test/acceptance/pages/lms/courseware.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,39 +1,45 @@
|
||||
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 __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)
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
@unguarded
|
||||
def url_path(self):
|
||||
return "discussion/forum/dummy/threads/" + self.thread_id
|
||||
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()
|
||||
|
||||
@@ -2,96 +2,132 @@
|
||||
Tests for discussion pages
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from .helpers import UniqueCourseTest
|
||||
from ..pages.studio.auto_auth import AutoAuthPage
|
||||
from ..pages.lms.discussion_single_thread import DiscussionSingleThreadPage
|
||||
from ..fixtures.course import CourseFixture
|
||||
from ..pages.lms.courseware import CoursewarePage
|
||||
from ..pages.lms.discussion import (
|
||||
DiscussionTabSingleThreadPage,
|
||||
InlineDiscussionPage,
|
||||
InlineDiscussionThreadPage
|
||||
)
|
||||
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ..fixtures.discussion import SingleThreadViewFixture, Thread, Response, Comment
|
||||
|
||||
|
||||
class DiscussionSingleThreadTest(UniqueCourseTest):
|
||||
class DiscussionResponsePaginationTestMixin(object):
|
||||
"""
|
||||
A mixin containing tests for response pagination for use by both inline
|
||||
discussion and the discussion tab
|
||||
"""
|
||||
|
||||
def setup_thread(self, num_responses, **thread_kwargs):
|
||||
"""
|
||||
Create a test thread with the given number of responses, passing all
|
||||
keyword arguments through to the Thread fixture, then invoke
|
||||
setup_thread_page.
|
||||
"""
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(id=thread_id, commentable_id=self.discussion_id, **thread_kwargs)
|
||||
)
|
||||
for i in range(num_responses):
|
||||
thread_fixture.addResponse(Response(id=str(i), body=str(i)))
|
||||
thread_fixture.push()
|
||||
self.setup_thread_page(thread_id)
|
||||
|
||||
def assert_response_display_correct(self, response_total, displayed_responses):
|
||||
"""
|
||||
Assert that various aspects of the display of responses are all correct:
|
||||
* Text indicating total number of responses
|
||||
* Presence of "Add a response" button
|
||||
* Number of responses actually displayed
|
||||
* Presence and text of indicator of how many responses are shown
|
||||
* Presence and text of button to load more responses
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.thread_page.get_response_total_text(),
|
||||
str(response_total) + " responses"
|
||||
)
|
||||
self.assertEqual(self.thread_page.has_add_response_button(), response_total != 0)
|
||||
self.assertEqual(self.thread_page.get_num_displayed_responses(), displayed_responses)
|
||||
self.assertEqual(
|
||||
self.thread_page.get_shown_responses_text(),
|
||||
(
|
||||
None if response_total == 0 else
|
||||
"Showing all responses" if response_total == displayed_responses else
|
||||
"Showing first {} responses".format(displayed_responses)
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.thread_page.get_load_responses_button_text(),
|
||||
(
|
||||
None if response_total == displayed_responses else
|
||||
"Load all responses" if response_total - displayed_responses < 100 else
|
||||
"Load next 100 responses"
|
||||
)
|
||||
)
|
||||
|
||||
def test_pagination_no_responses(self):
|
||||
self.setup_thread(0)
|
||||
self.assert_response_display_correct(0, 0)
|
||||
|
||||
def test_pagination_few_responses(self):
|
||||
self.setup_thread(5)
|
||||
self.assert_response_display_correct(5, 5)
|
||||
|
||||
def test_pagination_two_response_pages(self):
|
||||
self.setup_thread(50)
|
||||
self.assert_response_display_correct(50, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(50, 50)
|
||||
|
||||
def test_pagination_exactly_two_response_pages(self):
|
||||
self.setup_thread(125)
|
||||
self.assert_response_display_correct(125, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(125, 125)
|
||||
|
||||
def test_pagination_three_response_pages(self):
|
||||
self.setup_thread(150)
|
||||
self.assert_response_display_correct(150, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(150, 125)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(150, 150)
|
||||
|
||||
def test_add_response_button(self):
|
||||
self.setup_thread(5)
|
||||
self.assertTrue(self.thread_page.has_add_response_button())
|
||||
self.thread_page.click_add_response_button()
|
||||
|
||||
def test_add_response_button_closed_thread(self):
|
||||
self.setup_thread(5, closed=True)
|
||||
self.assertFalse(self.thread_page.has_add_response_button())
|
||||
|
||||
|
||||
class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginationTestMixin):
|
||||
"""
|
||||
Tests for the discussion page displaying a single thread
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionSingleThreadTest, self).setUp()
|
||||
super(DiscussionTabSingleThreadTest, self).setUp()
|
||||
self.discussion_id = "test_discussion_{}".format(uuid4().hex)
|
||||
|
||||
# Create a course to register for
|
||||
CourseFixture(**self.course_info).install()
|
||||
|
||||
self.user_id = AutoAuthPage(self.browser, course_id=self.course_id).visit().get_user_id()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
def setup_thread(self, thread, num_responses):
|
||||
view = SingleThreadViewFixture(thread=thread)
|
||||
for i in range(num_responses):
|
||||
view.addResponse(Response(id=str(i), body=str(i)))
|
||||
view.push()
|
||||
|
||||
def test_no_responses(self):
|
||||
self.setup_thread(Thread(id="0_responses"), 0)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "0_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "0 responses")
|
||||
self.assertFalse(page.has_add_response_button())
|
||||
self.assertEqual(page.get_num_displayed_responses(), 0)
|
||||
self.assertEqual(page.get_shown_responses_text(), None)
|
||||
self.assertIsNone(page.get_load_responses_button_text())
|
||||
|
||||
def test_few_responses(self):
|
||||
self.setup_thread(Thread(id="5_responses"), 5)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "5 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 5)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertIsNone(page.get_load_responses_button_text())
|
||||
|
||||
def test_two_response_pages(self):
|
||||
self.setup_thread(Thread(id="50_responses"), 50)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "50_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "50 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 25)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 25 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load all responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 50)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), None)
|
||||
|
||||
def test_three_response_pages(self):
|
||||
self.setup_thread(Thread(id="150_responses"), 150)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "150_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "150 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 25)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 25 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load next 100 responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 125)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 125 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load all responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 150)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), None)
|
||||
|
||||
def test_add_response_button(self):
|
||||
self.setup_thread(Thread(id="5_responses"), 5)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses")
|
||||
page.visit()
|
||||
self.assertTrue(page.has_add_response_button())
|
||||
page.click_add_response_button()
|
||||
|
||||
def test_add_response_button_closed_thread(self):
|
||||
self.setup_thread(Thread(id="5_responses_closed", closed=True), 5)
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses_closed")
|
||||
page.visit()
|
||||
self.assertFalse(page.has_add_response_button())
|
||||
def setup_thread_page(self, thread_id):
|
||||
self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201
|
||||
self.thread_page.visit()
|
||||
|
||||
|
||||
class DiscussionCommentDeletionTest(UniqueCourseTest):
|
||||
@@ -119,7 +155,7 @@ class DiscussionCommentDeletionTest(UniqueCourseTest):
|
||||
def test_comment_deletion_as_student(self):
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_deletable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_visible("comment_other_author"))
|
||||
@@ -129,7 +165,7 @@ class DiscussionCommentDeletionTest(UniqueCourseTest):
|
||||
def test_comment_deletion_as_moderator(self):
|
||||
self.setup_user(roles=['Moderator'])
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_deletable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_deletable("comment_other_author"))
|
||||
@@ -168,7 +204,7 @@ class DiscussionCommentEditTest(UniqueCourseTest):
|
||||
def test_edit_comment_as_student(self):
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_visible("comment_other_author"))
|
||||
@@ -178,7 +214,7 @@ class DiscussionCommentEditTest(UniqueCourseTest):
|
||||
def test_edit_comment_as_moderator(self):
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_editable("comment_other_author"))
|
||||
@@ -188,7 +224,7 @@ class DiscussionCommentEditTest(UniqueCourseTest):
|
||||
def test_cancel_comment_edit(self):
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
original_body = page.get_comment_body("comment_self_author")
|
||||
@@ -200,7 +236,7 @@ class DiscussionCommentEditTest(UniqueCourseTest):
|
||||
"""Only one editor should be visible at a time within a single response"""
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_editable("comment_other_author"))
|
||||
@@ -224,3 +260,44 @@ class DiscussionCommentEditTest(UniqueCourseTest):
|
||||
page.cancel_comment_edit("comment_self_author", original_body)
|
||||
self.assertFalse(page.is_comment_editor_visible("comment_self_author"))
|
||||
self.assertTrue(page.is_add_comment_visible("response1"))
|
||||
|
||||
|
||||
class InlineDiscussionTest(UniqueCourseTest, DiscussionResponsePaginationTestMixin):
|
||||
"""
|
||||
Tests for inline discussions
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(InlineDiscussionTest, self).setUp()
|
||||
self.discussion_id = "test_discussion_{}".format(uuid4().hex)
|
||||
CourseFixture(**self.course_info).add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion",
|
||||
metadata={"discussion_id": self.discussion_id}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
CoursewarePage(self.browser, self.course_id).visit()
|
||||
self.discussion_page = InlineDiscussionPage(self.browser, self.discussion_id)
|
||||
|
||||
def setup_thread_page(self, thread_id):
|
||||
self.discussion_page.expand_discussion()
|
||||
self.assertEqual(self.discussion_page.get_num_displayed_threads(), 1)
|
||||
self.thread_page = InlineDiscussionThreadPage(self.browser, thread_id) # pylint:disable=W0201
|
||||
self.thread_page.expand()
|
||||
|
||||
def test_initial_render(self):
|
||||
self.assertFalse(self.discussion_page.is_discussion_expanded())
|
||||
|
||||
def test_expand_discussion_empty(self):
|
||||
self.discussion_page.expand_discussion()
|
||||
self.assertEqual(self.discussion_page.get_num_displayed_threads(), 0)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user