Merge remote-tracking branch 'edx/master' into andya/merge-hotfix-2014-08-29

This commit is contained in:
Calen Pennington
2014-09-02 09:38:25 -04:00
114 changed files with 4825 additions and 3846 deletions

View File

@@ -77,32 +77,6 @@ def marketing_link_context_processor(request):
)
def header_footer_context_processor(request):
"""
A django context processor to pass feature flags through to all Django
Templates that are related to the display of the header and footer in
the edX platform.
"""
# TODO: ECOM-136 Remove this processor with the corresponding header and footer feature flags.
return dict(
[
("ENABLE_NEW_EDX_HEADER", settings.FEATURES.get("ENABLE_NEW_EDX_HEADER", False)),
("ENABLE_NEW_EDX_FOOTER", settings.FEATURES.get("ENABLE_NEW_EDX_FOOTER", False))
]
)
def open_source_footer_context_processor(request):
"""
Checks the site name to determine whether to use the edX.org footer or the Open Source Footer.
"""
return dict(
[
("IS_EDX_DOMAIN", settings.FEATURES.get('IS_EDX_DOMAIN', False))
]
)
def render_to_string(template_name, dictionary, context=None, namespace='main'):
# see if there is an override template defined in the microsite

View File

@@ -1,7 +1,6 @@
from mock import patch, Mock
import unittest
import ddt
from django.conf import settings
from django.http import HttpResponse
@@ -11,16 +10,11 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
import edxmako.middleware
from edxmako import add_lookup, LOOKUP
from edxmako.shortcuts import (
marketing_link,
render_to_string,
header_footer_context_processor,
open_source_footer_context_processor
)
from edxmako.shortcuts import marketing_link, render_to_string
from student.tests.factories import UserFactory
from util.testing import UrlResetMixin
@ddt.ddt
class ShortcutsTests(UrlResetMixin, TestCase):
"""
Test the edxmako shortcuts file
@@ -40,26 +34,6 @@ class ShortcutsTests(UrlResetMixin, TestCase):
link = marketing_link('ABOUT')
self.assertEquals(link, expected_link)
@ddt.data((True, True), (False, False), (False, True), (True, False))
@ddt.unpack
def test_header_and_footer(self, header_setting, footer_setting):
with patch.dict('django.conf.settings.FEATURES', {
'ENABLE_NEW_EDX_HEADER': header_setting,
'ENABLE_NEW_EDX_FOOTER': footer_setting,
}):
result = header_footer_context_processor({})
self.assertEquals(footer_setting, result.get('ENABLE_NEW_EDX_FOOTER'))
self.assertEquals(header_setting, result.get('ENABLE_NEW_EDX_HEADER'))
@ddt.data(True, False)
@ddt.unpack
def test_edx_footer(self, expected_result):
with patch.dict('django.conf.settings.FEATURES', {
'IS_EDX_DOMAIN': expected_result
}):
result = open_source_footer_context_processor({})
self.assertEquals(expected_result, result.get('IS_EDX_DOMAIN'))
class AddLookupTests(TestCase):
"""

View File

@@ -46,7 +46,8 @@ from student.models import (
Registration, UserProfile, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user,
CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory, UserSignupSource
create_comments_service_user, PasswordHistory, UserSignupSource,
anonymous_id_for_user
)
from student.forms import PasswordResetFormNoActive
@@ -92,6 +93,9 @@ from util.password_policy_validators import (
from third_party_auth import pipeline, provider
from xmodule.error_module import ErrorDescriptor
import analytics
from eventtracking import tracker
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
@@ -381,6 +385,10 @@ def register_user(request, extra_context=None):
'username': '',
}
# We save this so, later on, we can determine what course motivated a user's signup
# if they actually complete the registration process
request.session['registration_course_id'] = context['course_id']
if extra_context is not None:
context.update(extra_context)
@@ -951,6 +959,31 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
if LoginFailures.is_feature_enabled():
LoginFailures.clear_lockout_counter(user)
# Track the user's sign in
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
analytics.identify(anonymous_id_for_user(user, None), {
'email': email,
'username': username,
})
# If the user entered the flow via a specific course page, we track that
registration_course_id = request.session.get('registration_course_id')
analytics.track(
user.id,
"edx.bi.user.account.authenticated",
{
'category': "conversion",
'label': registration_course_id
},
context={
'Google Analytics': {
'clientId': tracking_context.get('client_id')
}
}
)
request.session['registration_course_id'] = None
if user is not None and user.is_active:
try:
# We do not log here, because we have a handler registered
@@ -1398,6 +1431,33 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
(user, profile, registration) = ret
dog_stats_api.increment("common.student.account_created")
email = post_vars['email']
# Track the user's registration
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
analytics.identify(anonymous_id_for_user(user, None), {
email: email,
username: username,
})
registration_course_id = request.session.get('registration_course_id')
analytics.track(
user.id,
"edx.bi.user.account.registered",
{
"category": "conversion",
"label": registration_course_id
},
context={
'Google Analytics': {
'clientId': tracking_context.get('client_id')
}
}
)
request.session['registration_course_id'] = None
create_comments_service_user(user)
context = {

View File

@@ -4,7 +4,7 @@ Loaded by Django's settings mechanism. Consequently, this module must not
invoke the Django armature.
"""
from social.backends import google, linkedin
from social.backends import google, linkedin, facebook
_DEFAULT_ICON_CLASS = 'icon-signin'
@@ -150,6 +150,26 @@ class LinkedInOauth2(BaseProvider):
return provider_details.get('fullname')
class FacebookOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system."""
BACKEND_CLASS = facebook.FacebookOAuth2
ICON_CLASS = 'icon-facebook'
NAME = 'Facebook'
SETTINGS = {
'SOCIAL_AUTH_FACEBOOK_KEY': None,
'SOCIAL_AUTH_FACEBOOK_SECRET': None,
}
@classmethod
def get_email(cls, provider_details):
return provider_details.get('email')
@classmethod
def get_name(cls, provider_details):
return provider_details.get('fullname')
class Registry(object):
"""Singleton registry of third-party auth providers.

View File

@@ -282,7 +282,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_register_response_before_pipeline_looks_correct(self, response):
"""Asserts a GET of /register not in the pipeline looks correct."""
self.assertEqual(200, response.status_code)
self.assertIn('Sign in with ' + self.PROVIDER_CLASS.NAME, response.content)
self.assertIn('Sign up with ' + self.PROVIDER_CLASS.NAME, response.content)
self.assert_signin_button_looks_functional(response.content, pipeline.AUTH_ENTRY_REGISTER)
def assert_signin_button_looks_functional(self, content, auth_entry):

View File

@@ -41,11 +41,11 @@ describe 'All Content', ->
it 'can update info', ->
@content.updateInfo {
ability: 'can_endorse',
ability: {'can_edit': true},
voted: true,
subscribed: true
}
expect(@content.get 'ability').toEqual 'can_endorse'
expect(@content.get 'ability').toEqual {'can_edit': true}
expect(@content.get 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true
@@ -77,3 +77,39 @@ describe 'All Content', ->
myComments = new Comments
myComments.add @comment1
expect(myComments.find('123')).toBe @comment1
it 'can be endorsed', ->
DiscussionUtil.loadRoles(
{"Moderator": [111], "Administrator": [222], "Community TA": [333]}
)
@discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99})
@discussionResponse = new Comment({id: 1, thread: @discussionThread})
@questionThread = new Thread({id: 1, thread_type: "question", user_id: 99})
@questionResponse = new Comment({id: 1, thread: @questionThread})
# mod
window.user = new DiscussionUser({id: 111})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# admin
window.user = new DiscussionUser({id: 222})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# TA
window.user = new DiscussionUser({id: 333})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# thread author
window.user = new DiscussionUser({id: 99})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# anyone else
window.user = new DiscussionUser({id: 999})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(false)

View File

@@ -3,4 +3,606 @@ class @DiscussionSpecHelper
@setUpGlobals = ->
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
window.$$course_id = "edX/999/test"
window.user = new DiscussionUser({id: "567", upvoted_ids: []})
window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []})
DiscussionUtil.setUser(window.user)
@makeModerator = () ->
DiscussionUtil.roleIds["Moderator"].push(parseInt(window.user.id))
@setUnderscoreFixtures = ->
appendSetFixtures("""
<div id="fixture-element"></div>
<!--
NOTE the html markup here comes from rendering lms/templates/discussion/_underscore_templates.html through a
browser and pasting the output. When that file changes, this one should be regenerated alongside it.
-->
<script aria-hidden="true" type="text/template" id="thread-template">
<article class="discussion-article" data-id="<%- id %>">
<div class="thread-wrapper">
<div class="forum-thread-main-wrapper">
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<ol class="responses js-marked-answer-list"></ol>
</div>
</div>
<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 js-response-list"/>
<div class="response-pagination"/>
<div class="post-status-closed bottom-post-status" style="display: none">
This thread is closed.
</div>
<form class="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>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="forum-thread-expand"><span class="icon icon-plus"/> Expand discussion</a>
<a href="javascript:void(0)" class="forum-thread-collapse"><span class="icon icon-minus"/> Collapse discussion</a>
</div>
</article>
</script>
<script aria-hidden="true" type="text/template" id="thread-show-template">
<div class="discussion-post">
<header>
<% if (obj.group_id) { %>
<div class="group-visibility-label"><%- obj.group_string%></div>
<% } %>
<div class="post-header-content">
<h1><%- title %></h1>
<p class="posted-details">
<%- thread_type %> posted <span class='timeago' title='<%- created_at %>'><%- created_at %></span> by <%= author_display %>
</p>
<div class="post-labels">
<span class="post-label-pinned"><i class="icon icon-pushpin"></i>Pinned</span>
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
<span class="post-label-closed"><i class="icon icon-lock"></i>Closed</span>
</div>
</div>
<div class="post-header-actions post-extended-content">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'post',
primaryActions: ['vote', 'follow'],
secondaryActions: ['pin', 'edit', 'delete', 'report', 'close']
}
)
%>
</div>
</header>
<div class="post-body"><%- body %></div>
<% if (mode == "tab" && obj.courseware_url) { %>
<div class="post-context"><%
var courseware_link = interpolate('<a href="%s">%s</a>', [courseware_url, _.escape(courseware_title)]);
print(interpolate('(this post is about %(courseware_title_linked)s)', {'courseware_title_linked': courseware_link}, true));
%></div>
<% } %>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-edit-template">
<div class="discussion-post edit-post-form">
<h1>Editing post</h1>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<label class="sr" for="edit-post-title">Edit post title</label>
<input type="text" id="edit-post-title" class="edit-post-title" name="title" value="<%-title %>" placeholder="Title">
</div>
<div class="form-row">
<div class="edit-post-body" name="body"><%- body %></div>
</div>
<input type="submit" id="edit-post-submit" class="post-update" value="Update post">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-template">
<div class="discussion-response"></div>
<a href="#" class="action-show-comments">
<%- interpolate('Show Comments (%(num_comments)s)', {num_comments: comments.length}, true) %>
<i class="icon icon-caret-down"></i>
</a>
<ol class="comments">
<li class="new-comment">
<form class="comment-form" data-id="<%- wmdId %>">
<ul class="discussion-errors"></ul>
<label class="sr" for="add-new-comment">Add a comment</label>
<div class="comment-body" id="add-new-comment" data-id="<%- wmdId %>"
data-placeholder="Add a comment..."></div>
<div class="comment-post-control">
<a class="discussion-submit-comment control-button" href="#">Submit</a>
</div>
</form>
</li>
</ol>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-show-template">
<header>
<div class="response-header-content">
<%= author_display %>
<p class="posted-details">
<span class="timeago" title="<%= created_at %>"><%= created_at %></span>
<% if (obj.endorsement) { %> - <%=
interpolate(
thread.get("thread_type") == "question" ?
(endorsement.username ? "marked as answer %(time_ago)s by %(user)s" : "marked as answer %(time_ago)s") :
(endorsement.username ? "endorsed %(time_ago)s by %(user)s" : "endorsed %(time_ago)s"),
{
'time_ago': '<span class="timeago" title="' + endorsement.time + '">' + endorsement.time + '</span>',
'user': endorser_display
},
true
)%><% } %>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
</div>
</div>
<div class="response-header-actions">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'response',
primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
</div>
</header>
<div class="response-body"><%- body %></div>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-edit-template">
<div class="edit-post-form">
<h1>Editing response</h1>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<div class="edit-post-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-response-submit"class="post-update" value="Update response">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="response-comment-show-template">
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'comment',
primaryActions: [],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
<p class="posted-details">
<%=
interpolate(
'posted %(time_ago)s by %(author)s',
{'time_ago': '<span class="timeago" title="' + created_at + '">' + created_at + '</span>', 'author': author_display},
true
)%>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="response-comment-edit-template">
<div class="edit-post-form" id="comment_<%- id %>">
<h1>Editing comment</h1>
<ul class="edit-comment-form-errors"></ul>
<div class="form-row">
<div class="edit-comment-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-comment-submit" class="post-update" value="Update comment">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-list-item-template">
<li data-id="<%- id %>" class="forum-nav-thread<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-0">
<%
var icon_class, sr_text;
if (thread_type == "discussion") {
icon_class = "icon-comments";
sr_text = "discussion";
} else if (endorsed) {
icon_class = "icon-ok";
sr_text = "answered question";
} else {
icon_class = "icon-question";
sr_text = "unanswered question";
}
%>
<span class="sr"><%= sr_text %></span>
<i class="icon <%= icon_class %>"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title"><%- title %></span>
<%
var labels = "";
if (pinned) {
labels += '<li class="post-label-pinned"><i class="icon icon-pushpin"></i>Pinned</li> ';
}
if (typeof(subscribed) != "undefined" && subscribed) {
labels += '<li class="post-label-following"><i class="icon icon-star"></i>Following</li> ';
}
if (staff_authored) {
labels += '<li class="post-label-by-staff"><i class="icon icon-user"></i>By: Staff</li> ';
}
if (community_ta_authored) {
labels += '<li class="post-label-by-community-ta"><i class="icon icon-user"></i>By: Community TA</li> ';
}
if (labels != "") {
print('<ul class="forum-nav-thread-labels">' + labels + '</ul>');
}
%>
</div><div class="forum-nav-thread-wrapper-2">
<span class="forum-nav-thread-votes-count">+<%=
interpolate(
'%(votes_up_count)s%(span_sr_open)s votes %(span_close)s',
{'span_sr_open': '<span class="sr">', 'span_close': '</span>', 'votes_up_count': votes['up_count']},
true
)
%></span>
<span class="forum-nav-thread-comments-count <% if (unread_comments_count > 0) { %>is-unread<% } %>">
<%
var fmt;
// Counts in data do not include the post itself, but the UI should
var data = {
'span_sr_open': '<span class="sr">',
'span_close': '</span>',
'unread_comments_count': unread_comments_count + (read ? 0 : 1),
'comments_count': comments_count + 1
};
if (unread_comments_count > 0) {
fmt = '%(comments_count)s %(span_sr_open)scomments (%(unread_comments_count)s unread comments)%(span_close)s';
} else {
fmt = '%(comments_count)s %(span_sr_open)scomments %(span_close)s';
}
print(interpolate(fmt, data, true));
%>
</span>
</div>
</a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="discussion-home">
<div class="discussion-article blank-slate">
<section class="home-header">
<span class="label">DISCUSSION HOME:</span>
<h1 class="home-title">Cohort Course</h1>
</section>
</div>
</script>
<script aria-hidden="true" type="text/template" id="search-alert-template">
<div class="search-alert" id="search-alert-<%- cid %>">
<div class="search-alert-content">
<p class="message"><%= message %></p>
</div>
<div class="search-alert-controls">
<a href="#" class="dismiss control control-dismiss"><i class="icon icon-remove"></i></a>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-template">
<form class="forum-new-post-form">
<ul class="post-errors" style="display: none"></ul>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Post type:
</span><fieldset class="field-input">
<input type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-question" value="question" checked>
<label for="<%= form_id %>-post-type-question" class="post-type-label">
<i class="icon icon-question"></i>
Question
</label>
<input type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-discussion" value="discussion">
<label for="<%= form_id %>-post-type-discussion" class="post-type-label">
<i class="icon icon-comments"></i>
Discussion
</label>
</fieldset>
</div><span class="field-help">
Questions raise issues that need answers. Discussions share ideas and start conversations.
</span>
</div>
<% if (mode=="tab") { %>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Topic Area:
</span><div class="field-input post-topic">
<a href="#" class="post-topic-button">
<span class="sr">Discussion topics; current selection is: </span>
<span class="js-selected-topic"></span>
<span class="drop-arrow" aria-hidden="true">▾</span>
</a>
<div class="topic-menu-wrapper">
<label class="topic-filter-label">
<span class="sr">Filter topics</span>
<input type="text" class="topic-filter-input" placeholder="Filter topics">
</label>
<ul class="topic-menu" role="menu"><%= topics_html %></ul>
</div>
</div>
</div><span class="field-help">
Add your post to a relevant topic to help others find it.
</span>
</div>
<% } %>
<% if (cohort_options) { %>
<div class="post-field">
<label class="field-label">
<span class="field-label-text">
Visible To:
</span><select class="field-input js-group-select" name="group_id">
<option value="">All Groups</option>
<% _.each(cohort_options, function(opt) { %>
<option value="<%= opt.value %>" <% if (opt.selected) { %>selected<% } %>><%- opt.text %></option>
<% }); %>
</select>
</label><div class="field-help">
Instructors can set whether a post in a cohorted topic is visible to all cohorts or only to a specific cohort.
</div>
</div>
<% } %>
<div class="post-field">
<label class="field-label">
<span class="sr">Title:</span>
<input type="text" class="field-input js-post-title" name="title" placeholder="Title">
</label><span class="field-help">
Add a clear and descriptive title to encourage participation.
</span>
</div>
<div class="post-field js-post-body editor" name="body" data-placeholder="Enter your question or comment…"></div>
<div class="post-options">
<label class="post-option is-enabled">
<input type="checkbox" name="follow" class="post-option-input js-follow" checked>
<i class="icon icon-star"></i>follow this post
</label>
<% if (allow_anonymous) { %>
<label class="post-option">
<input type="checkbox" name="anonymous" class="post-option-input js-anon">
post anonymously
</label>
<% } %>
<% if (allow_anonymous_to_peers) { %>
<label class="post-option">
<input type="checkbox" name="anonymous_to_peers" class="post-option-input js-anon-peers">
post anonymously to classmates
</label>
<% } %>
</div>
<div>
<input type="submit" class="submit" value="Add Post">
<a href="#" class="cancel">Cancel</a>
</div>
</form>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem" class="topic-menu-item">
<a href="#" class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_cohorted %>"><%- text %></a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem" class="topic-menu-item">
<span class="topic-title"><%- text %></span>
<ul role="menu" class="topic-submenu"><%= entries %></ul>
</li>
</script>
<script type="text/template" id="forum-action-endorse">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-endorse" role="checkbox" aria-checked="false">
<span class="sr">Endorse</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Endorse</span>
<span class="label-checked">Unendorse</span>
</span>
<span class="action-icon"><i class="icon icon-ok"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-answer">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-answer" role="checkbox" aria-checked="false">
<span class="sr">Mark as Answer</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Mark as Answer</span>
<span class="label-checked">Unmark as Answer</span>
</span>
<span class="action-icon"><i class="icon icon-ok"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-follow">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-follow" role="checkbox" aria-checked="false">
<span class="sr">Follow</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Follow</span>
<span class="label-checked">Unfollow</span>
</span>
<span class="action-icon"><i class="icon icon-star"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-vote">
<li class="actions-item">
<a href="#" class="action-button action-vote" role="checkbox" aria-checked="false">
<span class="sr">Vote</span>
<span class="sr js-sr-vote-count"></span>
<span class="action-label" aria-hidden="true">
<span class="js-visual-vote-count"></span>
</span>
<span class="action-icon" aria-hidden="true">
<i class="icon icon-plus"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-report">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-report" role="checkbox" aria-checked="false">
<span class="sr">Report abuse</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Report</span>
<span class="label-checked">Unreport</span>
</span>
<span class="action-icon">
<i class="icon icon-flag"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-pin">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-pin" role="checkbox" aria-checked="false">
<span class="sr">Pin</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Pin</span>
<span class="label-checked">Unpin</span>
</span>
<span class="action-icon">
<i class="icon icon-pushpin"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-close">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-close" role="checkbox" aria-checked="false">
<span class="sr">Close</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Close</span>
<span class="label-checked">Open</span>
</span>
<span class="action-icon">
<i class="icon icon-lock"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-edit">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
<span class="action-label">Edit</span>
<span class="action-icon"><i class="icon icon-pencil"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-delete">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-delete" role="button">
<span class="action-label">Delete</span>
<span class="action-icon"><i class="icon icon-remove"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-actions">
<ul class="<%= contentType %>-actions-list">
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<li class="actions-item is-visible">
<div class="more-wrapper">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>">
<span class="action-label">More</span>
<span class="action-icon"><i class="icon icon-ellipsis-horizontal"></i></span>
</a>
<div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false">
<ul class="actions-dropdown-list">
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
</ul>
</div>
</div>
</li>
</ul>
</script>
<script aria-hidden="true" type="text/template" id="post-user-display-template">
<% if (username) { %>
<a href="<%- user_url %>" class="username"><%- username %></a>
<% if (is_community_ta) { %>
<span class="user-label-community-ta">Community TA</span>
<% } else if (is_staff) { %>
<span class="user-label-staff">Staff</span>
<% } %>
<% } else { %>
anonymous
<% } %>
</script>
""")

View File

@@ -0,0 +1,27 @@
describe 'DiscussionUtil', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe "updateWithUndo", ->
it "calls through to safeAjax with correct params, and reverts the model in case of failure", ->
deferred = $.Deferred()
spyOn($, "ajax").andReturn(deferred)
spyOn(DiscussionUtil, "safeAjax").andCallThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# the ajax request should fire and the model should be updated
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar"}, "error message")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: "world", number: 42})
# the error message callback should be set up correctly
spyOn(DiscussionUtil, "discussionAlert")
DiscussionUtil.safeAjax.mostRecentCall.args[0].error()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalledWith("Sorry", "error message")
# if the ajax call ends in failure, the model state should be reverted
deferred.reject()
expect(model.attributes).toEqual({hello: false, number: 42})

View File

@@ -1,26 +1,7 @@
describe "DiscussionContentView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<header>
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class='votes-count-number'>0</span> <span class="sr">votes (click to vote)</span></a>
<h1>Post Title</h1>
<p class="posted-details">
<a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a>
<span title="2013-05-08T17:34:07Z" class="timeago">less than a minute ago</span>
</p>
</header>
<div class="post-body"><p>Post body.</p></div>
<div data-tooltip="Report Misuse" data-role="thread-flag" class="discussion-flag-abuse notflagged">
<i class="icon"></i><span class="flag-label">Report Misuse</span></div>
<div data-tooltip="pin this thread" class="admin-pin discussion-pin notpinned">
<i class="icon"></i><span class="pin-label">Pin Thread</span></div>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: '01234567',
@@ -35,7 +16,8 @@ describe "DiscussionContentView", ->
}
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post'))
@view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
@@ -59,15 +41,3 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual []
it 'renders the vote button properly', ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it 'votes correctly', ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, false)
it 'unvotes correctly', ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, false)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)

View File

@@ -6,7 +6,23 @@ describe "DiscussionThreadListView", ->
<script type="text/template" id="thread-list-item-template">
<li data-id="<%- id %>" class="forum-nav-thread<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-1">
<div class="forum-nav-thread-wrapper-0">
<%
var icon_class, sr_text;
if (thread_type == "discussion") {
icon_class = "icon-comments";
sr_text = "discussion";
} else if (endorsed) {
icon_class = "icon-ok";
sr_text = "answered question";
} else {
icon_class = "icon-question";
sr_text = "unanswered question";
}
%>
<span class="sr"><%= sr_text %></span>
<i class="icon <%= icon_class %>"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title"><%- title %></span>
<%
var labels = "";
@@ -27,9 +43,6 @@ describe "DiscussionThreadListView", ->
}
%>
</div><div class="forum-nav-thread-wrapper-2">
<% if (endorsed) { %>
<span class="forum-nav-thread-endorsed"><i class="icon icon-ok"></i><span class="sr">Endorsed response</span></span>
<% } %>
<span class="forum-nav-thread-votes-count">+<%=
interpolate(
'%(votes_up_count)s%(span_sr_open)s votes %(span_close)s',
@@ -84,9 +97,6 @@ describe "DiscussionThreadListView", ->
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">All Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-flagged">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-flag"></i>Flagged Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-following">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-star"></i>Posts I'm Following</a>
</li>
@@ -98,7 +108,7 @@ describe "DiscussionThreadListView", ->
<ul class="forum-nav-browse-submenu">
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "child"}'
data-discussion-id="child"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Child</a>
@@ -106,7 +116,7 @@ describe "DiscussionThreadListView", ->
</ul>
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "sibling"}'
data-discussion-id="sibling"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Sibling</a>
@@ -115,7 +125,7 @@ describe "DiscussionThreadListView", ->
</li>
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "other"}'
data-discussion-id="other"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Other Category</a>
@@ -124,13 +134,21 @@ describe "DiscussionThreadListView", ->
</div>
<div class="forum-nav-thread-list-wrapper">
<div class="forum-nav-refine-bar">
<span class="forum-nav-sort">
<label class="forum-nav-filter-main">
<select class="forum-nav-filter-main-control">
<option value="all">Show all</option>
<option value="unread">Unread</option>
<option value="unanswered">Unanswered</option>
<option value="flagged">Flagged</option>
</select>
</label>
<label class="forum-nav-sort">
<select class="forum-nav-sort-control">
<option value="date">by recent activity</option>
<option value="comments">by most activity</option>
<option value="votes">by most votes</option>
</select>
</span>
</label>
</div>
</div>
<div class="search-alerts"></div>
@@ -150,21 +168,21 @@ describe "DiscussionThreadListView", ->
<div class="forum-nav"></div>
"""
@threads = [
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "1",
title: "Thread1",
votes: {up_count: '20'},
comments_count: 1,
created_at: '2013-04-03T20:08:39Z',
}),
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "2",
title: "Thread2",
votes: {up_count: '42'},
comments_count: 2,
created_at: '2013-04-03T20:07:39Z',
}),
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "3",
title: "Thread3",
votes: {up_count: '12'},
@@ -179,20 +197,8 @@ describe "DiscussionThreadListView", ->
@view = new DiscussionThreadListView({collection: @discussion, el: $(".forum-nav")})
@view.render()
makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
unread_comments_count: 0,
comments_count: 0,
}
$.extend(thread, props)
renderSingleThreadWithProps = (props) ->
makeView(new Discussion([new Thread(makeThreadWithProps(props))])).render()
makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render()
makeView = (discussion) ->
return new DiscussionThreadListView(
@@ -200,6 +206,31 @@ describe "DiscussionThreadListView", ->
collection: discussion
)
expectFilter = (filterVal) ->
$.ajax.andCallFake((params) ->
_.each(["unread", "unanswered", "flagged"], (paramName)->
if paramName == filterVal
expect(params.data[paramName]).toEqual(true)
else
expect(params.data[paramName]).toBeUndefined()
)
{always: ->}
)
describe "should filter correctly", ->
_.each(["all", "unread", "unanswered", "flagged"], (filterVal) ->
it "for #{filterVal}", ->
expectFilter(filterVal)
@view.$(".forum-nav-filter-main-control").val(filterVal).change()
expect($.ajax).toHaveBeenCalled()
)
it "search should clear filter", ->
expectFilter(null)
@view.$(".forum-nav-filter-main-control").val("flagged")
@view.searchFor("foobar")
expect(@view.$(".forum-nav-filter-main-control").val()).toEqual("all")
checkThreadsOrdering = (view, sort_order, type) ->
expect(view.$el.find(".forum-nav-thread").children().length).toEqual(3)
expect(view.$el.find(".forum-nav-thread:nth-child(1) .forum-nav-thread-title").text()).toEqual(sort_order[0])
@@ -384,14 +415,21 @@ describe "DiscussionThreadListView", ->
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).not.toHaveBeenCalled()
describe "endorsed renders correctly", ->
it "when absent", ->
renderSingleThreadWithProps({})
expect($(".forum-nav-thread-endorsed").length).toEqual(0)
describe "post type renders correctly", ->
it "for discussion", ->
renderSingleThreadWithProps({thread_type: "discussion"})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-comments")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("discussion")
it "when present", ->
renderSingleThreadWithProps({endorsed: true})
expect($(".forum-nav-thread-endorsed").length).toEqual(1)
it "for answered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: true})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-ok")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question")
it "for unanswered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: false})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-question")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("unanswered question")
describe "post labels render correctly", ->
beforeEach ->
@@ -487,7 +525,7 @@ describe "DiscussionThreadListView", ->
expect(visibleItems).toEqual(expectedItems)
it "should be case-insensitive", ->
checkFilter("flagged", ["Flagged Discussions"])
checkFilter("other", ["Other Category"])
it "should match partial words", ->
checkFilter("ateg", ["Other Category"])

View File

@@ -1,89 +1,144 @@
describe "DiscussionThreadShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
<div class="admin-pin discussion-pin notpinned" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-pushpin"></i>
<span class="pin-label">Pin Thread</span>
</div>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@threadData = {
id: "dummy",
user_id: user.id,
user_id: @user.id,
username: @user.get('username'),
course_id: $$course_id,
title: "dummy title",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: "42"}
votes: {up_count: 42},
thread_type: "discussion",
closed: false,
pinned: false,
type: "thread" # TODO - silly that this needs to be explicitly set
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($(".discussion-post"))
@view.setElement($("#fixture-element"))
@spyOn(@view, "convertMath")
it "renders the vote correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
describe "voting", ->
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true)
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click"))
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click"))
describe "renderPinned", ->
describe "for an unpinned thread", ->
it "renders correctly when pinning is allowed", ->
@thread.updateInfo({ability: {can_openclose: true}})
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).not.toHaveClass("pinned")
expect(pinElem).toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pin Thread")
expect(pinElem).not.toHaveAttr("data-tooltip")
expect(pinElem).toHaveAttr("aria-pressed", "false")
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
# If pinning is not allowed, the pinning UI is not present, so no
# test is needed
describe "pinning", ->
describe "for a pinned thread", ->
beforeEach ->
@thread.set("pinned", true)
expectPinnedRendered = (view, model) ->
pinned = model.get('pinned')
button = view.$el.find(".action-pin")
expect(button.hasClass("is-checked")).toBe(pinned)
expect(button.attr("aria-checked")).toEqual(pinned.toString())
it "renders correctly when unpinning is allowed", ->
@thread.updateInfo({ability: {can_openclose: true}})
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).toHaveClass("pinned")
expect(pinElem).not.toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned<span class='sr'>, click to unpin</span>")
expect(pinElem).toHaveAttr("data-tooltip", "Click to unpin")
expect(pinElem).toHaveAttr("aria-pressed", "true")
it "renders the pinned state correctly", ->
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', false)
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', true)
@view.render()
expectPinnedRendered(@view, @thread)
it "renders correctly when unpinning is not allowed", ->
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).toHaveClass("pinned")
expect(pinElem).not.toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned")
expect(pinElem).not.toHaveAttr("data-tooltip")
expect(pinElem).not.toHaveAttr("aria-pressed")
it "exposes the pinning control only to authorized users", ->
@thread.updateInfo({ability: {can_openclose: false}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist()
@thread.updateInfo({ability: {can_openclose: true}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
it "pinning button activates on appropriate events", ->
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".admin-pin")
it "handles events correctly", ->
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the closed label when appropriate', ->
expectOneElement(@view, '.post-label-closed', false)
@thread.set('closed', true)
expectOneElement(@view, '.post-label-closed')
it 'displays the pinned label when appropriate', ->
expectOneElement(@view, '.post-label-pinned', false)
@thread.set('pinned', true)
expectOneElement(@view, '.post-label-pinned')
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "author display", ->
beforeEach ->
@thread.set('user_url', 'test_user_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_user')
expect(element.find('a.username').attr('href')).toEqual('test_user_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders correctly for a student-authored thread", ->
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-authored thread", ->
@thread.set('community_ta_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-authored thread", ->
@thread.set('staff_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, true)
it "renders correctly for an anonymously-authored thread", ->
@thread.set('username', null)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
expect($el.find('a.username').length).toEqual(0)
expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)

View File

@@ -1,106 +0,0 @@
describe "DiscussionThreadInlineView", ->
beforeEach ->
setFixtures(
"""
<script type="text/template" id="_inline_thread">
<article class="discussion-article">
<div class="non-cohorted-indicator"/>
<div class="post-body"/>
<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-body"/>
<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.showView, "convertMath")
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)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@thread.set("body", new Array(100).join("test "))
@view.abbreviateBody()
expect(@thread.get("body")).not.toEqual(@thread.get("abbreviatedBody"))
@view.render()
@view.expandPost()
expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("body"))
expect(@view.showView.convertMath).toHaveBeenCalled()
@view.showView.convertMath.reset()
@view.collapsePost()
expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("abbreviatedBody"))
expect(@view.showView.convertMath).toHaveBeenCalled()

View File

@@ -1,80 +1,188 @@
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"/>
"""
)
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
jasmine.Clock.useMock()
@threadData = {
id: "dummy"
}
@threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
@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")
spyOn(DiscussionThreadShowView.prototype, "convertMath")
spyOn(DiscussionContentView.prototype, "makeWmdEditor")
spyOn(ThreadResponseView.prototype, "renderShowView")
describe "response count and pagination", ->
renderWithContent = (view, content) ->
DiscussionViewSpecHelper.setNextResponseContent(content)
view.render()
jasmine.Clock.tick(100)
renderWithContent = (view, content) ->
DiscussionViewSpecHelper.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)
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toBeGreaterThan(0)
content.each (i, elem) ->
expect($(elem).is(":visible")).toEqual(visible)
it "correctly render for a thread with no responses", ->
renderWithContent(@view, {resp_total: 0, children: []})
assertRenderedCorrectly(@view, "0 responses", null, null)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".forum-thread-expand", not expanded)
assertContentVisible(view, ".forum-thread-collapse", expanded)
it "correctly render for a thread with one response", ->
renderWithContent(@view, {resp_total: 1, children: [{}]})
assertRenderedCorrectly(@view, "1 response", "Showing all responses", null)
assertResponseCountAndPaginationCorrect = (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 one additional page", ->
renderWithContent(@view, {resp_total: 2, children: [{}]})
assertRenderedCorrectly(@view, "2 responses", "Showing first response", "Load all responses")
describe "tab mode", ->
beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "tab"})
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 "response count and pagination", ->
it "correctly render for a thread with no responses", ->
renderWithContent(@view, {resp_total: 0, children: []})
assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null)
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", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "5 responses", "Showing all responses", null)
it "correctly render for a thread with one response", ->
renderWithContent(@view, {resp_total: 1, children: [{}]})
assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null)
it "correctly re-render when one page remains", ->
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 render for a thread with one additional page", ->
renderWithContent(@view, {resp_total: 2, children: [{}]})
assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses")
it "correctly re-render when multiple pages remain", ->
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")
it "correctly render for a thread with multiple additional pages", ->
renderWithContent(@view, {resp_total: 111, children: [{}, {}]})
assertResponseCountAndPaginationCorrect(@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: [{}]})
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null)
it "correctly re-render when one page remains", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 42, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses")
it "correctly re-render when multiple pages remain", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 111, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
describe "inline mode", ->
beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "inline"})
describe "render", ->
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.expand()
assertExpandedContentVisible(@view, true)
@view.collapse()
assertExpandedContentVisible(@view, false)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longBody = new Array(100).join("test ")
expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140)
@thread.set("body", longBody)
@view.render()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.reset()
@view.expand()
expect($(".post-body").text()).toEqual(longBody)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.reset()
@view.collapse()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
describe "for question threads", ->
beforeEach ->
@thread.set("thread_type", "question")
@view = new DiscussionThreadView(
{model: @thread, el: $("#fixture-element"), mode: "tab"}
)
renderTestCase = (view, numEndorsed, numNonEndorsed) ->
generateContent = (idStart, idEnd) ->
_.map(_.range(idStart, idEnd), (i) -> {"id": "#{i}"})
renderWithContent(
view,
{
endorsed_responses: generateContent(0, numEndorsed),
non_endorsed_responses: generateContent(numEndorsed, numEndorsed + numNonEndorsed),
non_endorsed_resp_total: numNonEndorsed
}
)
expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed)
expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed)
assertResponseCountAndPaginationCorrect(
view,
ngettext(
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}response",
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}responses",
numNonEndorsed
)
if numNonEndorsed then "Showing all responses" else null,
null
)
_.each({"no": 0, "one": 1, "many": 5}, (numEndorsed, endorsedDesc) ->
_.each({"no": 0, "one": 1, "many": 5}, (numNonEndorsed, nonEndorsedDesc) ->
it "renders correctly with #{endorsedDesc} marked answer(s) and #{nonEndorsedDesc} response(s)", ->
renderTestCase(@view, numEndorsed, numNonEndorsed)
)
)
it "handles pagination correctly", ->
renderWithContent(
@view,
{
endorsed_responses: [{id: "1"}, {id: "2"}],
non_endorsed_responses: [{id: "3"}, {id: "4"}, {id: "5"}],
non_endorsed_resp_total: 42
}
)
DiscussionViewSpecHelper.setNextResponseContent({
# Add an endorsed response; it should be rendered
endorsed_responses: [{id: "1"}, {id: "2"}, {id: "6"}],
non_endorsed_responses: [{id: "7"}, {id: "8"}, {id: "9"}],
non_endorsed_resp_total: 41
})
@view.$el.find(".load-response-button").click()
expect($(".js-marked-answer-list .discussion-response").length).toEqual(3)
expect($(".js-response-list .discussion-response").length).toEqual(6)
assertResponseCountAndPaginationCorrect(
@view,
"41 other responses",
"Showing first 6 responses",
"Load all responses"
)

View File

@@ -1,101 +1,63 @@
class @DiscussionViewSpecHelper
@expectVoteRendered = (view, voted) ->
button = view.$el.find(".vote-btn")
if voted
expect(button.hasClass("is-cast")).toBe(true)
expect(button.attr("aria-pressed")).toEqual("true")
expect(button.attr("data-tooltip")).toEqual("remove vote")
expect(button.text()).toEqual("43 votes (click to remove your vote)")
else
expect(button.hasClass("is-cast")).toBe(false)
expect(button.attr("aria-pressed")).toEqual("false")
expect(button.attr("data-tooltip")).toEqual("vote")
expect(button.text()).toEqual("42 votes (click to vote)")
@makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
thread_type: "discussion",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
unread_comments_count: 0,
comments_count: 0,
abuse_flaggers: [],
body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
}
$.extend(thread, props)
@expectVoteRendered = (view, model, user) ->
button = view.$el.find(".action-vote")
expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$")
@checkRenderVote = (view, model) ->
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, true)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "43"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
expect(params.url.toString()).toEqual(expectedUrl)
return deferred
)
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
view.render()
view.$el.find(".action-vote").trigger(event)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
deferred.resolve()
# Check idempotence
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
@checkUpvote = (view, model, user, event) ->
expect(model.id in user.get('upvoted_ids')).toBe(false)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
@checkUnvote = (view, model, modelData, checkRendering) ->
window.user.vote(model)
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "42"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
)
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
@checkToggleVote = (view, model) ->
event = {preventDefault: ->}
spyOn(event, "preventDefault")
spyOn(view, "vote").andCallFake(() -> window.user.vote(model))
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model))
expect(window.user.voted(model)).toBe(false)
view.toggleVote(event)
expect(view.vote).toHaveBeenCalled()
expect(view.unvote).not.toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(1)
view.vote.reset()
view.unvote.reset()
expect(window.user.voted(model)).toBe(true)
view.toggleVote(event)
expect(view.vote).not.toHaveBeenCalled()
expect(view.unvote).toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(2)
@checkUnvote = (view, model, user, event) ->
user.vote(model)
expect(model.id in user.get('upvoted_ids')).toBe(true)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(user.get('upvoted_ids')).toEqual([])
expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
@checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc)
@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper
expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".vote-btn")
@checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) ->
$.ajax.andCallFake(

View File

@@ -3,101 +3,53 @@ describe "NewPostView", ->
beforeEach ->
setFixtures(
"""
<article class="new-post-article" style="display: block;">
<div class="inner-wrapper">
<form class="new-post-form">
<div class="left-column" >
</div>
</form>
<div class="discussion-body">
<div class="discussion-column">
<article class="new-post-article" style="display: block;"></article>
</div>
</article>
</div>
<script aria-hidden="true" type="text/template" id="new-post-tab-template">
<div class="inner-wrapper">
<form class="new-post-form">
<div class="left-column">
'<%= topic_dropdown_html %>
'<%= options_html %>'
<script aria-hidden="true" type="text/template" id="new-post-template">
<form class="forum-new-post-form">
<% if (mode=="tab") { %>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Topic Area:
</span>
<div class="field-input post-topic">
<a href="#" class="post-topic-button">
<span class="sr">${_("Discussion topics; current selection is: ")}</span>
<span class="js-selected-topic"></span>
<span class="drop-arrow" aria-hidden="true">▾</span>
</a>
<div class="topic-menu-wrapper">
<ul class="topic-menu" role="menu"><%= topics_html %></ul>
</div>
</div>
</div>
</form>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-inline-template">
<div class="inner-wrapper">
<div class="new-post-form-errors">
</div>
<form class="new-post-form">
<%= editor_html %>
<%= options_html %>
</form>
</div>
<% } %>
<select class="js-group-select">
<option value="">All Groups</option>
<option value="1">Group 1</option>
<option value="2">Group 2</option>
</select>
</form>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem"><a href="#" class="topic" data-discussion_id="<%- id %>" aria-describedby="topic-name-span-<%- id %>" cohorted="<%- is_cohorted %>"><%- text %></a></li>
<li role="menuitem">
<a href="#" class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_cohorted %>"><%- text %></a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem">
<a href="#"><span class="category-menu-span"><%- text %></span></a>
<ul role="menu"><%= entries %></ul>
<span class="topic-title"><%- text %></span>
<ul role="menu" class="topic-submenu"><%= entries %></ul>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-topic-dropdown-template">
<span class="topic-dropdown-label" id="topic-dropdown-label">Create new post about:</span>
<div class="form-topic-drop">
<a href="#" aria-labelledby="topic-dropdown-label" class="topic_dropdown_button">${_("Show All Discussions")}<span class="drop-arrow" aria-hidden="true">▾</span></a>
<div class="topic_menu_wrapper">
<div class="topic_menu_search" role="menu">
<label class="sr" for="browse-topic-newpost">Filter List</label>
<input type="text" id="browse-topic-newpost" class="form-topic-drop-search-input" placeholder="Filter discussion areas">
</div>
<ul class="topic_menu" role="menu"><%= topics_html %></ul>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-options-template">
<div class="options">
<input type="checkbox" name="follow" class="discussion-follow" id="new-post-follow" checked><label for="new-post-follow">follow this post</label>
<% if (allow_anonymous) { %>
<br>
<input type="checkbox" name="anonymous" class="discussion-anonymous" id="new-post-anonymous">
<label for="new-post-anonymous">post anonymously</label>
<% } %>
<% if (allow_anonymous_to_peers) { %>
<br>
<input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers">
<label for="new-post-anonymous-to-peers">post anonymously to classmates</label>
<% } %>
<% if (cohort_options) { %>
<div class="form-group-label choose-cohort">
## Translators: This labels the selector for which group of students can view a post
Make visible to:
<select class="group-filter-select new-post-group" name="group_id">
<option value="">All Groups</option>
<% _.each(cohort_options, function(opt) { %>
<option value="<%= opt.value %>" <% if (opt.selected) { %>selected<% } %>><%- opt.text %></option>
<% }); %>
</select>
</div>
<% } %>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-editor-template">
<div class="form-row">
<label class="sr" for="new-post-title">new post title</label>
<input type="text" id="new-post-title" class="new-post-title" name="title" placeholder="Title">
</div>
<div class="form-row">
<div class="new-post-body" name="body" placeholder="Enter your question or comment…"></div>
</div>
<input type="submit" id="new-post-submit" class="submit" value="Add post">
<a href="#" class="new-post-cancel">Cancel</a>
</script>
"""
)
window.$$course_id = "edX/999/test"
@@ -106,30 +58,31 @@ describe "NewPostView", ->
describe "Drop down works correct", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"category_map": {
"subcategories": {
"Basic Question Types": {
"subcategories": {},
"children": ["Selection From Options"],
"entries": {
"Selection From Options": {
"sort_key": null,
"is_cohorted": true,
"id": "cba3e4cd91d0466b9ac50926e495b76f"
}
},
},
},
"children": ["Basic Question Types"],
"entries": {}
},
"allow_anonymous": true,
"allow_anonymous_to_peers": true
})
@view = new NewPostView(
el: $(".new-post-article"),
collection: @discussion,
course_settings: new DiscussionCourseSettings({
"category_map": {
"subcategories": {
"Basic Question Types": {
"subcategories": {},
"children": ["Selection From Options"],
"entries": {
"Selection From Options": {
"sort_key": null,
"is_cohorted": true,
"id": "cba3e4cd91d0466b9ac50926e495b76f"
}
},
},
},
"children": ["Basic Question Types"],
"entries": {}
},
"allow_anonymous": true,
"allow_anonymous_to_peers": true
}),
course_settings: @course_settings,
mode: "tab"
)
@view.render()
@@ -140,16 +93,16 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width + 1
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
expect(complete_text+'').toEqual(dropdown_text)
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(complete_text).toEqual(dropdown_text)
it "completely show just sub-category", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width - 10
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("")).toEqual(0)
expect(dropdown_text).toContain(@selected_option_text)
@@ -158,8 +111,8 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width - parent_width
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("")).toEqual(0)
expect(dropdown_text.lastIndexOf("")).toBeGreaterThan(0)
@@ -167,10 +120,49 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = @view.getNameWidth(@selected_option_text) + 100
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("/ span>")).toEqual(-1)
describe "cohort selector", ->
renderWithCohortedTopics = (course_settings, view, isCohortedFirst) ->
course_settings.set(
"category_map",
{
"children": if isCohortedFirst then ["Cohorted", "Non-Cohorted"] else ["Non-Cohorted", "Cohorted"],
"entries": {
"Non-Cohorted": {
"sort_key": null,
"is_cohorted": false,
"id": "non-cohorted"
},
"Cohorted": {
"sort_key": null,
"is_cohorted": true,
"id": "cohorted"
}
}
}
)
view.render()
expectCohortSelectorEnabled = (view, enabled) ->
expect(view.$(".js-group-select").prop("disabled")).toEqual(not enabled)
if not enabled
expect(view.$(".js-group-select option:selected").attr("value")).toEqual("")
it "is disabled with non-cohorted default topic and enabled by selecting cohorted topic", ->
renderWithCohortedTopics(@course_settings, @view, false)
expectCohortSelectorEnabled(@view, false)
@view.$("a.topic-title[data-discussion-id=cohorted]").click()
expectCohortSelectorEnabled(@view, true)
it "is enabled with cohorted default topic and disabled by selecting non-cohorted topic", ->
renderWithCohortedTopics(@course_settings, @view, true)
expectCohortSelectorEnabled(@view, true)
@view.$("a.topic-title[data-discussion-id=non-cohorted]").click()
expectCohortSelectorEnabled(@view, false)
it "posts to the correct URL", ->
topicId = "test_topic"
spyOn($, "ajax").andCallFake(
@@ -189,5 +181,5 @@ describe "NewPostView", ->
topicId: topicId
)
view.render()
view.$(".new-post-form").submit()
view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()

View File

@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in
setFixtures """
<ol class="responses"></ol>
<script id="response-comment-show-template" type="text/template">
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label"></span></div>
<div style="display:none" class="discussion-delete-comment action-delete" data-role="comment-delete" data-tooltip="Delete Comment" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-remove"></i><span class="sr delete-label">Delete Comment</span></div>
<div style="display:none" class="discussion-edit-comment action-edit" data-tooltip="Edit Comment" role="button" tabindex="0">
<i class="icon icon-pencil"></i><span class="sr">Edit Comment</span></div>
<p class="posted-details">&ndash;posted <span class="timeago" title="<%- created_at %>"><%- created_at %></span> by
<% if (obj.username) { %>
<a href="<%- user_url %>" class="profile-link"><%- username %></a>
<% } else {print('anonymous');} %>
</p>
</div>
</script>
"""
DiscussionSpecHelper.setUnderscoreFixtures()
# set up a model for a new Comment
@comment = new Comment {
@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', ->
beforeEach ->
spyOn(@view, 'renderAttrs')
spyOn(@view, 'markAsStaff')
it 'produces the correct HTML', ->
@view.render()
expect(@view.el.innerHTML).toContain('"discussion-flag-abuse notflagged"')
it 'can be flagged for abuse', ->
@comment.flagAbuse()
@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', ->
@view.bind "comment:edit", triggerTarget
@view.edit()
expect(triggerTarget).toHaveBeenCalled()
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')

View File

@@ -10,19 +10,9 @@ describe 'ResponseCommentView', ->
abuse_flaggers: ['123']
roles: ['Student']
}
setFixtures """
<script id="response-comment-show-template" type="text/template">
<div id="response-comment-show-div"/>
</script>
<script id="response-comment-edit-template" type="text/template">
<div id="response-comment-edit-div">
<div class="edit-comment-body"><textarea/></div>
<ul class="edit-comment-form-errors"/>
</div>
</script>
<div id="response-comment-fixture"/>
"""
@view = new ResponseCommentView({ model: @comment, el: $("#response-comment-fixture") })
DiscussionSpecHelper.setUnderscoreFixtures()
@view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor")
@view.render()
@@ -95,8 +85,7 @@ describe 'ResponseCommentView', ->
expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", makeEventSpy()
expect(@view.edit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(1)
expect(@view.$("#response-comment-edit-div").length).toEqual(0)
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', ->
@@ -107,8 +96,7 @@ describe 'ResponseCommentView', ->
expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(0)
expect(@view.$("#response-comment-edit-div").length).toEqual(1)
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', ->
@@ -135,6 +123,8 @@ describe 'ResponseCommentView', ->
describe 'update', ->
beforeEach ->
@updatedBody = "updated body"
# Markdown code creates the editor, so we simulate that here
@view.$el.find(".edit-comment-body").html($("<textarea></textarea>"))
@view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit')
spyOn($, "ajax").andCallFake(

View File

@@ -1,40 +1,220 @@
describe "ThreadResponseShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@thread = new Thread({"thread_type": "discussion"})
@commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
endorsed: false,
abuse_flaggers: [],
votes: {up_count: "42"}
votes: {up_count: 42},
type: "comment"
}
@comment = new Comment(@commentData)
@view = new ThreadResponseShowView({ model: @comment })
@view.setElement($(".discussion-post"))
@comment.set("thread", @thread)
@view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") })
it "renders the vote correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
# Avoid unnecessary boilerplate
spyOn(ThreadResponseShowView.prototype, "convertMath")
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @comment, @commentData, true)
@view.render()
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @commentData, true)
describe "voting", ->
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @comment)
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "renders endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": "test_endorser",
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"marked as answer less than a minute ago by " + endorsement.username
)
it "renders anonymous endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "renders endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": "test_endorser",
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"endorsed less than a minute ago by " + endorsement.username
)
it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "re-renders correctly when endorsement changes", ->
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set("thread_type", "question")
@view.render()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
it "allows a moderator to mark an answer in a question thread", ->
DiscussionUtil.loadRoles({"Moderator": parseInt(window.user.id)})
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "allows the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "does not allow the author of a discussion thread to endorse", ->
@thread.set({
"thread_type": "discussion",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-endorse")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
it "does not allow a student who is not the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "endorser display", ->
beforeEach ->
@comment.set('endorsement', {
"username": "test_endorser",
"time": new Date().toISOString()
})
spyOn(DiscussionUtil, 'urlFor').andReturn('test_endorser_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_endorser')
expect(element.find('a.username').attr('href')).toEqual('test_endorser_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders nothing when the response has not been endorsed", ->
@comment.set('endorsement', null)
expect(@view.getEndorserDisplay()).toBeNull()
it "renders correctly for a student-endorsed response", ->
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-endorsed response", ->
spyOn(DiscussionUtil, 'isTA').andReturn(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-endorsed response", ->
spyOn(DiscussionUtil, 'isStaff').andReturn(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, true)

View File

@@ -1,19 +1,43 @@
describe 'ThreadResponseView', ->
beforeEach ->
setFixtures """
<script id="thread-response-template" type="text/template">
<div/>
</script>
<div id="thread-response-fixture"/>
"""
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@response = new Comment {
children: [{}, {}]
}
@view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")})
@view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render")
describe 'renderComments', ->
it 'hides "show comments" link if collapseComments is not set', ->
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [] }
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).not.toBeVisible()
expect(@view.$(".action-show-comments")).toBeVisible()
@view.$(".action-show-comments").click()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView()

View File

@@ -9,7 +9,6 @@ if Backbone?
actions:
editable: '.admin-edit'
can_reply: '.discussion-reply'
can_endorse: '.admin-endorse'
can_delete: '.admin-delete'
can_openclose: '.admin-openclose'
@@ -21,6 +20,9 @@ if Backbone?
can: (action) ->
(@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) ->
if info
@set('ability', info.ability)
@@ -106,13 +108,21 @@ if Backbone?
@get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @
isFlagged: ->
user = DiscussionUtil.getUser()
flaggers = @get("abuse_flaggers")
user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0))
incrementVote: (increment) ->
newVotes = _.clone(@get("votes"))
newVotes.up_count = newVotes.up_count + increment
@set("votes", newVotes)
vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1
@trigger "change", @
@incrementVote(1)
unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1
@trigger "change", @
@incrementVote(-1)
class @Thread extends @Content
urlMappers:
@@ -187,6 +197,13 @@ if Backbone?
count += comment.getCommentsCount() + 1
count
canBeEndorsed: =>
user_id = window.user.get("id")
user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id)
)
class @Comments extends Backbone.Collection
model: Comment

View File

@@ -34,6 +34,8 @@ if Backbone?
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode
when 'search'
url = DiscussionUtil.urlFor 'search'
@@ -43,9 +45,6 @@ if Backbone?
data['commentable_ids'] = options.commentable_ids
when 'all'
url = DiscussionUtil.urlFor 'threads'
when 'flagged'
data['flagged'] = true
url = DiscussionUtil.urlFor 'search'
when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']

View File

@@ -1,8 +1,13 @@
class @DiscussionFilter
# TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
# for use with a very similar category dropdown in the New Post form. The two menus' implementations
# should be merged into a single reusable view.
@filterDrop: (e) ->
$drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper')
$drop = $(e.target).parents('.topic-menu-wrapper')
query = $(e.target).val()
$items = $drop.find('a')
$items = $drop.find('.topic-menu-item')
if(query.length == 0)
$items.removeClass('hidden')
@@ -10,19 +15,14 @@ class @DiscussionFilter
$items.addClass('hidden')
$items.each (i) ->
thisText = $(this).not('.unread').text()
$(this).parents('ul').siblings('a').not('.unread').each (i) ->
thisText = thisText + ' ' + $(this).text();
test = true
terms = thisText.split(' ')
path = $(this).parents(".topic-menu-item").andSelf()
pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ").toLowerCase()
if(thisText.toLowerCase().search(query.toLowerCase()) == -1)
test = false
if(test)
if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
$(this).removeClass('hidden')
# show children
$(this).parent().find('a').removeClass('hidden');
$(this).find('.topic-menu-item').removeClass('hidden');
# show parents
$(this).parents('ul').siblings('a').removeClass('hidden');
$(this).parents('.topic-menu-item').removeClass('hidden');

View File

@@ -7,7 +7,7 @@ if Backbone?
"click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .new-post-cancel": "hideNewPost"
"click .cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
@@ -101,7 +101,7 @@ if Backbone?
@newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline"
_.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView(
@@ -124,7 +124,7 @@ if Backbone?
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread
threadView = new DiscussionThreadView el: article, model: thread, mode: "inline"
threadView.render()
@threadviews.unshift threadView

View File

@@ -25,7 +25,7 @@ if Backbone?
@newPostView.render()
$('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
$('.new-post-cancel').bind "click", @hideNewPost
@newPostView.$('.cancel').bind "click", @hideNewPost
allThreads: ->
@nav.updateSidebar()
@@ -45,8 +45,12 @@ if Backbone?
if(@main)
@main.cleanup()
@main.undelegateEvents()
unless($(".forum-content").is(":visible"))
$(".forum-content").fadeIn()
if(@newPost.is(":visible"))
@newPost.fadeOut()
@main = new DiscussionThreadView(el: $(".discussion-column"), model: @thread)
@main = new DiscussionThreadView(el: $(".forum-content"), model: @thread, mode: "tab")
@main.render()
@main.on "thread:responses:rendered", =>
@nav.updateSidebar()
@@ -59,8 +63,17 @@ if Backbone?
@navigate("", trigger: true)
showNewPost: (event) =>
@newPost.slideDown(300)
$('.new-post-title').focus()
$('.forum-content').fadeOut(
duration: 200
complete: =>
@newPost.fadeIn(200)
$('.new-post-title').focus()
)
hideNewPost: (event) =>
@newPost.slideUp(300)
@newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200)
)

View File

@@ -21,15 +21,14 @@ class @DiscussionUtil
@setUser: (user) ->
@user = user
@getUser: () ->
@user
@loadRoles: (roles)->
@roleIds = roles
@loadFlagModerator: (what)->
@isFlagModerator = ((what=="True") or (what == 1))
@loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles"))
@loadFlagModerator($("#discussion-container").data("flag-moderator"))
@isStaff: (user_id) ->
user_id ?= @user?.id
@@ -41,6 +40,9 @@ class @DiscussionUtil
ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) ->
for id, info of infos
Content.getContent(id).updateInfo(info)
@@ -159,6 +161,13 @@ class @DiscussionUtil
params["$loading"].loaded()
return request
@updateWithUndo: (model, updates, safeAjaxParams, errorMsg) ->
if errorMsg
safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg)
undo = _.pick(model.attributes, _.keys(updates))
model.set(updates)
@safeAjax(safeAjaxParams).fail(() -> model.set(undo))
@bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ')
@@ -167,7 +176,7 @@ class @DiscussionUtil
@formErrorHandler: (errorsField) ->
(xhr, textStatus, error) ->
makeErrorElem = (message) ->
$("<li>").addClass("new-post-form-error").html(message)
$("<li>").addClass("post-error").html(message)
errorsField.empty().show()
if xhr.status == 400
response = JSON.parse(xhr.responseText)

View File

@@ -8,40 +8,6 @@ if Backbone?
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
attrRenderer:
endorsed: (endorsed) ->
if endorsed
@$(".action-endorse").show().addClass("is-endorsed")
else
if @model.get('ability')?.can_endorse
@$(".action-endorse").show()
else
@$(".action-endorse").hide()
@$(".action-endorse").removeClass("is-endorsed")
closed: (closed) ->
return if not @$(".action-openclose").length
return if not @$(".post-status-closed").length
if closed
@$(".post-status-closed").show()
@$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Close"), gettext("Open")))
@$(".discussion-reply-new").hide()
else
@$(".post-status-closed").hide()
@$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Open"), gettext("Close")))
@$(".discussion-reply-new").show()
voted: (voted) ->
votes_point: (votes_point) ->
comments_count: (comments_count) ->
subscribed: (subscribed) ->
if subscribed
@$(".dogear").addClass("is-followed").attr("aria-checked", "true")
else
@$(".dogear").removeClass("is-followed").attr("aria-checked", "false")
ability: (ability) ->
for action, selector of @abilityRenderer
if not ability[action]
@@ -51,23 +17,22 @@ if Backbone?
abilityRenderer:
editable:
enable: -> @$(".action-edit").closest("li").show()
disable: -> @$(".action-edit").closest("li").hide()
enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden")
can_delete:
enable: -> @$(".action-delete").closest("li").show()
disable: -> @$(".action-delete").closest("li").hide()
can_endorse:
enable: ->
@$(".action-endorse").show().css("cursor", "auto")
disable: ->
@$(".action-endorse").css("cursor", "default")
if not @model.get('endorsed')
@$(".action-endorse").hide()
else
@$(".action-endorse").show()
enable: -> @$(".action-delete").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-delete").closest(".actions-item").addClass("is-hidden")
can_openclose:
enable: -> @$(".action-openclose").closest("li").show()
disable: -> @$(".action-openclose").closest("li").hide()
enable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").removeClass("is-hidden")
)
disable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").addClass("is-hidden")
)
renderPartialAttrs: ->
for attr, value of @model.changedAttributes()
@@ -79,15 +44,6 @@ if Backbone?
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
$: (selector) ->
@$local.find(selector)
initLocal: ->
@$local = @$el.children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
makeWmdEditor: (cls_identifier) =>
if not @$el.find(".wmd-panel").length
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier
@@ -103,116 +59,238 @@ if Backbone?
initialize: ->
@initLocal()
@model.bind('change', @renderPartialAttrs, @)
toggleFollowing: (event) =>
event.preventDefault()
$elem = $(event.target)
url = null
if not @model.get('subscribed')
@model.follow()
url = @model.urlFor("follow")
else
@model.unfollow()
url = @model.urlFor("unfollow")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
toggleFlagAbuse: (event) =>
event.preventDefault()
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@unFlagAbuse()
else
@flagAbuse()
flagAbuse: =>
url = @model.urlFor("flagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
###
note, we have to clone the array in order to trigger a change event
###
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.push(window.user.id)
@model.set('abuse_flaggers', temp_array)
unFlagAbuse: =>
url = @model.urlFor("unFlagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.pop(window.user.id)
# if you're an admin, clear this
if DiscussionUtil.isFlagModerator
temp_array = []
@model.set('abuse_flaggers', temp_array)
renderVote: =>
button = @$el.find(".vote-btn")
voted = window.user.voted(@model)
voteNum = @model.get("votes")["up_count"]
button.toggleClass("is-cast", voted)
button.attr("aria-pressed", voted)
button.attr("data-tooltip", if voted then gettext("remove vote") else gettext("vote"))
buttonTextFmt =
if voted
ngettext(
"vote (click to remove your vote)",
"votes (click to remove your vote)",
voteNum
)
else
ngettext(
"vote (click to vote)",
"votes (click to vote)",
voteNum
)
buttonTextFmt = "%(voteNum)s%(startSrSpan)s " + buttonTextFmt + "%(endSrSpan)s"
buttonText = interpolate(
buttonTextFmt,
{voteNum: voteNum, startSrSpan: "<span class='sr'>", endSrSpan: "</span>"},
true
@listenTo(@model, "change:endorsed", =>
if @model instanceof Comment
@trigger("comment:endorse")
)
button.html("<span class='plus-icon'/>" + buttonText)
class @DiscussionContentShowView extends DiscussionContentView
events:
_.reduce(
[
[".action-follow", "toggleFollow"],
[".action-answer", "toggleEndorse"],
[".action-endorse", "toggleEndorse"],
[".action-vote", "toggleVote"],
[".action-more", "toggleSecondaryActions"],
[".action-pin", "togglePin"],
[".action-edit", "edit"],
[".action-delete", "_delete"],
[".action-report", "toggleReport"],
[".action-close", "toggleClose"],
],
(obj, event) =>
selector = event[0]
funcName = event[1]
obj["click #{selector}"] = (event) -> @[funcName](event)
obj["keydown #{selector}"] = (event) -> DiscussionUtil.activateOnSpace(event, @[funcName])
obj
,
{}
)
updateButtonState: (selector, checked) =>
$button = @$(selector)
$button.toggleClass("is-checked", checked)
$button.attr("aria-checked", checked)
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
subscribed: (subscribed) ->
@updateButtonState(".action-follow", subscribed)
endorsed: (endorsed) ->
selector = if @model.get("thread").get("thread_type") == "question" then ".action-answer" else ".action-endorse"
@updateButtonState(selector, endorsed)
$button = @$(selector)
$button.closest(".actions-item").toggleClass("is-hidden", not @model.canBeEndorsed())
$button.toggleClass("is-checked", endorsed)
votes: (votes) ->
selector = ".action-vote"
@updateButtonState(selector, window.user.voted(@model))
button = @$el.find(selector)
numVotes = votes.up_count
button.find(".js-sr-vote-count").html(
interpolate(
ngettext("currently %(numVotes)s vote", "currently %(numVotes)s votes", numVotes),
{numVotes: numVotes},
true
)
)
button.find(".js-visual-vote-count").html(
interpolate(
ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes),
{numVotes: numVotes},
true
)
)
pinned: (pinned) ->
@updateButtonState(".action-pin", pinned)
@$(".post-label-pinned").toggleClass("is-hidden", not pinned)
abuse_flaggers: (abuse_flaggers) ->
flagged = @model.isFlagged()
@updateButtonState(".action-report", flagged)
@$(".post-label-reported").toggleClass("is-hidden", not flagged)
closed: (closed) ->
@updateButtonState(".action-close", closed)
@$(".post-label-closed").toggleClass("is-hidden", not closed)
})
toggleSecondaryActions: (event) =>
event.preventDefault()
event.stopPropagation()
@secondaryActionsExpanded = !@secondaryActionsExpanded
@$(".action-more").toggleClass("is-expanded", @secondaryActionsExpanded)
@$(".actions-dropdown").
toggleClass("is-expanded", @secondaryActionsExpanded).
attr("aria-expanded", @secondaryActionsExpanded)
if @secondaryActionsExpanded
if event.type == "keydown"
@$(".action-list-item:first").focus()
$("body").on("click", @toggleSecondaryActions)
$("body").on("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").on("blur", @handleSecondaryActionBlur)
else
$("body").off("click", @toggleSecondaryActions)
$("body").off("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").off("blur", @handleSecondaryActionBlur)
handleSecondaryActionEscape: (event) =>
if event.keyCode == 27 # Esc
@toggleSecondaryActions(event)
@$(".action-more").focus()
handleSecondaryActionBlur: (event) =>
setTimeout(
=>
if @secondaryActionsExpanded && @$(".actions-dropdown :focus").length == 0
@toggleSecondaryActions(event)
,
10
)
toggleFollow: (event) =>
event.preventDefault()
is_subscribing = not @model.get("subscribed")
url = @model.urlFor(if is_subscribing then "follow" else "unfollow")
if is_subscribing
msg = gettext("We had some trouble subscribing you to this thread. Please try again.")
else
msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{"subscribed": is_subscribing},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleEndorse: (event) =>
event.preventDefault()
is_endorsing = not @model.get("endorsed")
url = @model.urlFor("endorse")
updates =
endorsed: is_endorsing
endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), time: new Date().toISOString()} else null
if @model.get('thread').get('thread_type') == 'question'
if is_endorsing
msg = gettext("We had some trouble marking this response as an answer. Please try again.")
else
msg = gettext("We had some trouble removing this response as an answer. Please try again.")
else
if is_endorsing
msg = gettext("We had some trouble marking this response endorsed. Please try again.")
else
msg = gettext("We had some trouble removing this endorsement. Please try again.")
beforeFunc = () => @trigger("comment:endorse")
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", data: {endorsed: is_endorsing}, beforeSend: beforeFunc, $elem: $(event.currentTarget)},
msg
).always(@trigger("comment:endorse")) # ensures UI components get updated to the correct state when ajax completes
toggleVote: (event) =>
event.preventDefault()
if window.user.voted(@model)
@unvote()
user = DiscussionUtil.getUser()
is_voting = not user.voted(@model)
url = @model.urlFor(if is_voting then "upvote" else "unvote")
updates =
upvoted_ids: (if is_voting then _.union else _.difference)(user.get('upvoted_ids'), [@model.id])
DiscussionUtil.updateWithUndo(
user,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
gettext("We had some trouble saving your vote. Please try again.")
).done(() => if is_voting then @model.vote() else @model.unvote())
togglePin: (event) =>
event.preventDefault()
is_pinning = not @model.get("pinned")
url = @model.urlFor(if is_pinning then "pinThread" else "unPinThread")
if is_pinning
msg = gettext("We had some trouble pinning this thread. Please try again.")
else
@vote()
msg = gettext("We had some trouble unpinning this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{pinned: is_pinning},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
vote: =>
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
toggleReport: (event) =>
event.preventDefault()
if @model.isFlagged()
is_flagging = false
msg = gettext("We had some trouble removing your flag on this post. Please try again.")
else
is_flagging = true
msg = gettext("We had some trouble reporting this post. Please try again.")
url = @model.urlFor(if is_flagging then "flagAbuse" else "unFlagAbuse")
updates =
abuse_flaggers: (if is_flagging then _.union else _.difference)(@model.get("abuse_flaggers"), [DiscussionUtil.getUser().id])
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
unvote: =>
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
toggleClose: (event) =>
event.preventDefault()
is_closing = not @model.get('closed')
if is_closing
msg = gettext("We had some trouble closing this thread. Please try again.")
else
msg = gettext("We had some trouble reopening this thread. Please try again.")
updates = {closed: is_closing}
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: @model.urlFor("close"), type: "POST", data: updates, $elem: $(event.currentTarget)},
msg
)
getAuthorDisplay: ->
_.template($("#post-user-display-template").html())(
username: @model.get('username') || null
user_url: @model.get('user_url')
is_community_ta: @model.get('community_ta_authored')
is_staff: @model.get('staff_authored')
)
getEndorserDisplay: ->
endorsement = @model.get('endorsement')
if endorsement and endorsement.username
_.template($("#post-user-display-template").html())(
username: endorsement.username
user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id)
is_community_ta: DiscussionUtil.isTA(endorsement.user_id)
is_staff: DiscussionUtil.isStaff(endorsement.user_id)
)
else
null

View File

@@ -10,6 +10,7 @@ if Backbone?
"change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: ->
@@ -75,7 +76,7 @@ if Backbone?
#TODO fix this entire chain of events
addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id")
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id").id == commentable_id)
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id)
@setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id')
@@ -173,7 +174,7 @@ if Backbone?
loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus()
options = {}
options = {filter: @filter}
switch @mode
when 'search'
options.search_text = @current_search
@@ -242,7 +243,7 @@ if Backbone?
goHome: ->
@template = _.template($("#discussion-home").html())
$(".discussion-column").html(@template)
$(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active")
$("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
@@ -363,26 +364,24 @@ if Backbone?
@discussionIds = ""
@$('.forum-nav-filter-cohort').show()
@retrieveAllThreads()
else if item.hasClass("forum-nav-browse-menu-flagged")
@discussionIds = ""
@$('.forum-nav-filter-cohort').hide()
@retrieveFlaggedThreads()
else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed()
@$('.forum-nav-filter-cohort').hide()
else
allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id").id
(i, elem) -> $(elem).data("discussion-id")
).get()
@retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true)
chooseCohort: (event) ->
chooseFilter: (event) =>
@filter = $(".forum-nav-filter-main-control :selected").val()
@retrieveFirstPage()
chooseCohort: (event) =>
@group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
@retrieveFirstPage()
retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
@@ -413,12 +412,6 @@ if Backbone?
@collection.reset()
@loadMorePages(event)
retrieveFlaggedThreads: (event)->
@collection.current_page = 0
@collection.reset()
@mode = 'flagged'
@loadMorePages(event)
sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
@@ -434,6 +427,7 @@ if Backbone?
searchFor: (text) ->
@clearSearchAlerts()
@clearFilters()
@mode = 'search'
@current_search = text
url = DiscussionUtil.urlFor("search")
@@ -499,6 +493,11 @@ if Backbone?
clearSearch: ->
@$(".forum-nav-search-input").val("")
@current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () =>
@mode = 'followed'

View File

@@ -1,42 +1,27 @@
if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
"click .admin-pin":
(event) -> @togglePin(event)
"keydown .admin-pin":
(event) -> DiscussionUtil.activateOnSpace(event, @togglePin)
"click .action-follow": "toggleFollowing"
"keydown .action-follow":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFollowing)
"click .action-edit": "edit"
"click .action-delete": "_delete"
"click .action-openclose": "toggleClosed"
$: (selector) ->
@$el.find(selector)
initialize: ->
class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
super()
@model.on "change", @updateModelDetails
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
renderTemplate: ->
@template = _.template($("#thread-show-template").html())
@template(@model.toJSON())
context = $.extend(
{
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid
},
@model.attributes,
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderVote()
@renderFlagged()
@renderPinned()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
@@ -44,60 +29,6 @@ if Backbone?
@highlight @$("h1,h3")
@
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
renderPinned: =>
pinElem = @$(".discussion-pin")
pinLabelElem = pinElem.find(".pin-label")
if @model.get("pinned")
pinElem.addClass("pinned")
pinElem.removeClass("notpinned")
if @model.can("can_openclose")
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
pinLabelElem.html(
interpolate(
gettext("Pinned%(start_sr_span)s, click to unpin%(end_span)s"),
{"start_sr_span": "<span class='sr'>", "end_span": "</span>"},
true
)
)
pinElem.attr("data-tooltip", gettext("Click to unpin"))
pinElem.attr("aria-pressed", "true")
else
pinLabelElem.html(gettext("Pinned"))
pinElem.removeAttr("data-tooltip")
pinElem.removeAttr("aria-pressed")
else
# If not pinned and not able to pin, pin is not shown
pinElem.removeClass("pinned")
pinElem.addClass("notpinned")
pinLabelElem.html(gettext("Pin Thread"))
pinElem.removeAttr("data-tooltip")
pinElem.attr("aria-pressed", "false")
updateModelDetails: =>
@renderVote()
@renderFlagged()
@renderPinned()
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
@@ -109,74 +40,6 @@ if Backbone?
_delete: (event) ->
@trigger "thread:_delete", event
togglePin: (event) =>
event.preventDefault()
if @model.get('pinned')
@unPin()
else
@pin()
pin: =>
url = @model.urlFor("pinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', true)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble pinning this thread. Please try again.")
unPin: =>
url = @model.urlFor("unPinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', false)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble unpinning this thread. Please try again.")
toggleClosed: (event) ->
$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) ->
$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)
highlight: (el) ->
if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))
class @DiscussionThreadInlineShowView extends DiscussionThreadShowView
renderTemplate: ->
@template = DiscussionUtil.getTemplate('_inline_thread_show')
params = @model.toJSON()
if @model.get('username')?
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
Mustache.render(@template, params)

View File

@@ -7,14 +7,25 @@ if Backbone?
events:
"click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) ->
@$el.find(selector)
initialize: ->
isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@createShowView()
@responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
renderTemplate: ->
@template = _.template($("#thread-template").html())
@@ -22,7 +33,6 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@initLocal()
@delegateEvents()
@renderShowView()
@@ -31,11 +41,53 @@ if Backbone?
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@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((=> @loadInitialResponses()), 100)
@
@responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {}))
if @isQuestion()
@markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true}))
if @mode == "tab"
# Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error
setTimeout((=> @loadInitialResponses()), 100)
@$(".post-tools").hide()
else # mode == "inline"
@collapse()
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@renderAddResponseButton()
})
expand: (event) ->
if event
event.preventDefault()
@$el.addClass("expanded")
@$el.find(".post-body").html(@model.get("body"))
@showView.convertMath()
@$el.find(".forum-thread-expand").hide()
@$el.find(".forum-thread-collapse").show()
@$el.find(".post-extended-content").show()
if not @loadedResponses
@loadInitialResponses()
collapse: (event) ->
if event
event.preventDefault()
@$el.removeClass("expanded")
@$el.find(".post-body").html(@getAbbreviatedBody())
@showView.convertMath()
@$el.find(".forum-thread-expand").show()
@$el.find(".forum-thread-collapse").hide()
@$el.find(".post-extended-content").hide()
getAbbreviatedBody: ->
cached = @model.get("abbreviatedBody")
if cached
cached
else
abbreviated = DiscussionUtil.abbreviateString @model.get("body"), 140
@model.set("abbreviatedBody", abbreviated)
abbreviated
cleanup: ->
if @responsesRequest?
@@ -54,9 +106,20 @@ if Backbone?
@responseRequest = null
success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info'])
@responses.add(data['content']['children'])
@renderResponseCountAndPagination(data['content']['resp_total'])
if @isQuestion()
@markedAnswers.add(data["content"]["endorsed_responses"])
@responses.add(
if @isQuestion()
then data["content"]["non_endorsed_responses"]
else data["content"]["children"]
)
@renderResponseCountAndPagination(
if @isQuestion()
then data["content"]["non_endorsed_resp_total"]
else data["content"]["resp_total"]
)
@trigger "thread:responses:rendered"
@loadedResponses = true
error: (xhr) =>
if xhr.status == 404
DiscussionUtil.discussionAlert(
@@ -75,16 +138,24 @@ if Backbone?
)
loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true)
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) =>
if @isQuestion() && @markedAnswers.length != 0
responseCountFormat = ngettext(
"%(numResponses)s other response",
"%(numResponses)s other responses",
responseTotal
)
else
responseCountFormat = ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
)
@$el.find(".response-count").html(
interpolate(
ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
),
responseCountFormat,
{numResponses: responseTotal},
true
)
@@ -126,17 +197,17 @@ if Backbone?
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton)
renderResponse: (response) =>
renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model)
view = new ThreadResponseView(model: response)
view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
@$el.find(".responses").append(view.el)
@$el.find(listSelector).append(view.el)
view.afterInsert()
renderAddResponseButton: ->
if @model.hasResponses() and @model.can('can_reply')
renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show()
else
@$el.find('div.add-response').hide()
@@ -150,9 +221,8 @@ if Backbone?
addComment: =>
@model.comment()
endorseThread: (endorsed) =>
is_endorsed = @$el.find(".is-endorsed").length
@model.set 'endorsed', is_endorsed
endorseThread: =>
@model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
submitComment: (event) ->
event.preventDefault()
@@ -162,7 +232,7 @@ if Backbone?
@setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread'))
@renderResponse(comment)
@renderResponseToList(comment, ".js-response-list")
@model.addComment()
@renderAddResponseButton()
@@ -209,6 +279,7 @@ if Backbone?
@model.set
title: newTitle
body: newBody
@model.unset("abbreviatedBody")
@createShowView()
@renderShowView()
@@ -232,9 +303,6 @@ if Backbone?
renderEditView: () ->
@renderSubView(@editView)
getShowViewClass: () ->
return DiscussionThreadShowView
createShowView: () ->
if @editView?
@@ -242,8 +310,7 @@ if Backbone?
@editView.$el.empty()
@editView = null
showViewClass = @getShowViewClass()
@showView = new showViewClass(model: @model)
@showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit

View File

@@ -1,71 +0,0 @@
if Backbone?
class @DiscussionThreadInlineView extends DiscussionThreadView
expanded = false
events:
"click .discussion-submit-post": "submitComment"
"click .expand-post": "expandPost"
"click .collapse-post": "collapsePost"
"click .add-response-btn": "scrollToAddResponse"
initialize: ->
super()
initLocal: ->
@$local = @$el.children(".discussion-article").children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
renderTemplate: () ->
if @model.has('group_id')
@template = DiscussionUtil.getTemplate("_inline_thread_cohorted")
else
@template = DiscussionUtil.getTemplate("_inline_thread")
if not @model.has('abbreviatedBody')
@abbreviateBody()
params = @model.toJSON()
Mustache.render(@template, params)
render: () ->
super()
@$el.find('.post-extended-content').hide()
@$el.find('.collapse-post').hide()
getShowViewClass: () ->
return DiscussionThreadInlineShowView
loadInitialResponses: () ->
if @expanded
super()
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140
@model.set('abbreviatedBody', abbreviated)
expandPost: (event) =>
@$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()
if not @expanded
@expanded = true
@loadInitialResponses()
collapsePost: (event) ->
curScroll = $(window).scrollTop()
postTop = @$el.offset().top
if postTop < curScroll
$('html, body').animate({scrollTop: postTop})
@$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()
createEditView: () ->
super()
@editView.bind "thread:update", @abbreviateBody

View File

@@ -10,72 +10,51 @@ if Backbone?
@topicId = options.topicId
render: () ->
context = _.clone(@course_settings.attributes)
_.extend(context, {
cohort_options: @getCohortOptions(),
mode: @mode,
form_id: @mode + (if @topicId then "-" + @topicId else "")
})
context.topics_html = @renderCategoryMap(@course_settings.get("category_map")) if @mode is "tab"
@$el.html(_.template($("#new-post-template").html(), context))
if @mode is "tab"
@$el.html(
_.template(
$("#new-post-tab-template").html(), {
topic_dropdown_html: @getTopicDropdownHTML(),
options_html: @getOptionsHTML(),
editor_html: @getEditorHTML()
}
)
)
# set up the topic dropdown in tab mode
@dropdownButton = @$(".topic_dropdown_button")
@topicMenu = @$(".topic_menu_wrapper")
@menuOpen = @dropdownButton.hasClass('dropped')
@topicId = @$(".topic").first().data("discussion_id")
@topicText = @getFullTopicName(@$(".topic").first())
$('.choose-cohort').hide() unless @$(".topic_menu li a").first().is("[cohorted=true]")
@setSelectedTopic()
else # inline
@$el.html(
_.template(
$("#new-post-inline-template").html(), {
options_html: @getOptionsHTML(),
editor_html: @getEditorHTML()
}
)
)
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
@dropdownButton = @$(".post-topic-button")
@topicMenu = @$(".topic-menu-wrapper")
@hideTopicDropdown()
@setTopic(@$("a.topic-title").first())
getTopicDropdownHTML: () ->
# populate the category menu (topic dropdown)
_renderCategoryMap = (map) ->
category_template = _.template($("#new-post-menu-category-template").html())
entry_template = _.template($("#new-post-menu-entry-template").html())
html = ""
for name in map.children
if name of map.entries
entry = map.entries[name]
html += entry_template({text: name, id: entry.id, is_cohorted: entry.is_cohorted})
else # subcategory
html += category_template({text: name, entries: _renderCategoryMap(map.subcategories[name])})
html
topics_html = _renderCategoryMap(@course_settings.get("category_map"))
_.template($("#new-post-topic-dropdown-template").html(), {topics_html: topics_html})
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "js-post-body"
getEditorHTML: () ->
_.template($("#new-post-editor-template").html(), {})
renderCategoryMap: (map) ->
category_template = _.template($("#new-post-menu-category-template").html())
entry_template = _.template($("#new-post-menu-entry-template").html())
html = ""
for name in map.children
if name of map.entries
entry = map.entries[name]
html += entry_template({text: name, id: entry.id, is_cohorted: entry.is_cohorted})
else # subcategory
html += category_template({text: name, entries: @renderCategoryMap(map.subcategories[name])})
html
getOptionsHTML: () ->
# cohort options?
getCohortOptions: () ->
if @course_settings.get("is_cohorted") and DiscussionUtil.isStaff()
user_cohort_id = $("#discussion-container").data("user-cohort-id")
cohort_options = _.map @course_settings.get("cohorts"), (cohort) ->
_.map @course_settings.get("cohorts"), (cohort) ->
{value: cohort.id, text: cohort.name, selected: cohort.id==user_cohort_id}
else
cohort_options = null
context = _.clone(@course_settings.attributes)
context.cohort_options = cohort_options
_.template($("#new-post-options-template").html(), context)
null
events:
"submit .new-post-form": "createPost"
"click .topic_dropdown_button": "toggleTopicDropdown"
"click .topic_menu_wrapper": "setTopic"
"click .topic_menu_search": "ignoreClick"
"keyup .form-topic-drop-search-input": DiscussionFilter.filterDrop
"submit .forum-new-post-form": "createPost"
"click .post-topic-button": "toggleTopicDropdown"
"click .topic-menu-wrapper": "handleTopicEvent"
"click .topic-filter-label": "ignoreClick"
"keyup .topic-filter-input": DiscussionFilter.filterDrop
"change .post-option-input": "postOptionChange"
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to ignore clicks in the search field and stop propagation.
@@ -83,15 +62,24 @@ if Backbone?
ignoreClick: (event) ->
event.stopPropagation()
postOptionChange: (event) ->
$target = $(event.target)
$optionElem = $target.closest(".post-option")
if $target.is(":checked")
$optionElem.addClass("is-enabled")
else
$optionElem.removeClass("is-enabled")
createPost: (event) ->
event.preventDefault()
title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val()
group = @$(".new-post-group option:selected").attr("value")
thread_type = @$(".post-type-input:checked").val()
title = @$(".js-post-title").val()
body = @$(".js-post-body").find(".wmd-input").val()
group = @$(".js-group-select option:selected").attr("value")
anonymous = false || @$("input.discussion-anonymous").is(":checked")
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
follow = false || @$("input.discussion-follow").is(":checked")
anonymous = false || @$(".js-anon").is(":checked")
anonymous_to_peers = false || @$(".js-anon-peers").is(":checked")
follow = false || @$(".js-follow").is(":checked")
url = DiscussionUtil.urlFor('create_thread', @topicId)
@@ -103,24 +91,27 @@ if Backbone?
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
thread_type: thread_type
title: title
body: body
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
error: DiscussionUtil.formErrorHandler(@$(".post-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
DiscussionUtil.clearFormErrors(@$(".new-post-form-errors"))
DiscussionUtil.clearFormErrors(@$(".post-errors"))
@$el.hide()
@$(".new-post-title").val("").attr("prev-text", "")
@$(".new-post-body textarea").val("").attr("prev-text", "")
@$(".js-post-title").val("").attr("prev-text", "")
@$(".js-post-body textarea").val("").attr("prev-text", "")
@$(".wmd-preview p").html("") # only line not duplicated in new post inline view
@collection.add thread
toggleTopicDropdown: (event) ->
event.preventDefault()
event.stopPropagation()
if @menuOpen
@hideTopicDropdown()
@@ -133,7 +124,6 @@ if Backbone?
@topicMenu.show()
$(".form-topic-drop-search-input").focus()
$("body").bind "keydown", @setActiveItem
$("body").bind "click", @hideTopicDropdown
# Set here because 1) the window might get resized and things could
@@ -146,28 +136,33 @@ if Backbone?
@dropdownButton.removeClass('dropped')
@topicMenu.hide()
$("body").unbind "keydown", @setActiveItem
$("body").unbind "click", @hideTopicDropdown
setTopic: (event) ->
$target = $(event.target)
if $target.data('discussion_id')
handleTopicEvent: (event) ->
event.preventDefault()
event.stopPropagation()
@setTopic($(event.target))
setTopic: ($target) ->
if $target.data('discussion-id')
@topicText = $target.html()
@topicText = @getFullTopicName($target)
@topicId = $target.data('discussion_id')
@topicId = $target.data('discussion-id')
@setSelectedTopic()
if $target.is('[cohorted=true]')
$('.choose-cohort').show();
if $target.data("cohorted")
$(".js-group-select").prop("disabled", false)
else
$('.choose-cohort').hide();
$(".js-group-select").val("")
$(".js-group-select").prop("disabled", true)
@hideTopicDropdown()
setSelectedTopic: ->
@dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>')
@$(".js-selected-topic").html(@fitName(@topicText))
getFullTopicName: (topicElement) ->
name = topicElement.html()
topicElement.parents('ul').not('.topic_menu').each ->
name = $(this).siblings('a').text() + ' / ' + name
topicElement.parents('.topic-submenu').each ->
name = $(this).siblings('.topic-title').text() + ' / ' + name
return name
getNameWidth: (name) ->
@@ -204,29 +199,3 @@ if Backbone?
name = gettext("") + " / " + rawName + " " + gettext("")
return name
setActiveItem: (event) ->
if event.which == 13
$(".topic_menu_wrapper .focused").click()
return
if event.which != 40 && event.which != 38
return
event.preventDefault()
items = $.makeArray($(".topic_menu_wrapper a").not(".hidden"))
index = items.indexOf($('.topic_menu_wrapper .focused')[0])
if event.which == 40
index = Math.min(index + 1, items.length - 1)
if event.which == 38
index = Math.max(index - 1, 0)
$(".topic_menu_wrapper .focused").removeClass("focused")
$(items[index]).addClass("focused")
itemTop = $(items[index]).parent().offset().top
scrollTop = $(".topic_menu").scrollTop()
itemFromTop = $(".topic_menu").offset().top - itemTop
scrollTarget = Math.min(scrollTop - itemFromTop, scrollTop)
scrollTarget = Math.max(scrollTop - itemFromTop - $(".topic_menu").height() + $(items[index]).height() + 20, scrollTarget)
$(".topic_menu").scrollTop(scrollTarget)

View File

@@ -1,40 +1,23 @@
if Backbone?
class @ResponseCommentShowView extends DiscussionContentView
events:
"click .action-delete":
(event) -> @_delete(event)
"keydown .action-delete":
(event) -> DiscussionUtil.activateOnSpace(event, @_delete)
"click .action-edit":
(event) -> @edit(event)
"keydown .action-edit":
(event) -> DiscussionUtil.activateOnSpace(event, @edit)
class @ResponseCommentShowView extends DiscussionContentShowView
tagName: "li"
initialize: ->
super()
@model.on "change", @updateModelDetails
abilityRenderer:
can_delete:
enable: -> @$(".action-delete").show()
disable: -> @$(".action-delete").hide()
editable:
enable: -> @$(".action-edit").show()
disable: -> @$(".action-edit").hide()
render: ->
@template = _.template($("#response-comment-show-template").html())
params = @model.toJSON()
@$el.html(
@template(
_.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay()
},
@model.attributes
)
)
)
@$el.html(@template(params))
@initLocal()
@delegateEvents()
@renderAttrs()
@renderFlagged()
@markAsStaff()
@$el.find(".timeago").timeago()
@convertMath()
@addReplyLink()
@@ -52,31 +35,8 @@ if Backbone?
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="staff-label">' + gettext('staff') + '</span>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="community-ta-label">' + gettext('Community TA') + '</span>')
_delete: (event) =>
@trigger "comment:_delete", event
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Misuse Reported, click to remove report"))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Report Misuse"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderFlagged()
edit: (event) =>
@trigger "comment:edit", event

View File

@@ -1,37 +1,27 @@
if Backbone?
class @ThreadResponseShowView extends DiscussionContentView
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .action-endorse": "toggleEndorse"
"click .action-delete": "_delete"
"click .action-edit": "edit"
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
$: (selector) ->
@$el.find(selector)
class @ThreadResponseShowView extends DiscussionContentShowView
initialize: ->
super()
@model.on "change", @updateModelDetails
@listenTo(@model, "change", @render)
renderTemplate: ->
@template = _.template($("#thread-response-show-template").html())
@template(@model.toJSON())
context = _.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay()
},
@model.attributes
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderVote()
@renderAttrs()
@renderFlagged()
@$el.find(".posted-details").timeago()
@$el.find(".posted-details .timeago").timeago()
@convertMath()
@markAsStaff()
@
convertMath: ->
@@ -39,54 +29,8 @@ if Backbone?
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.addClass("staff")
@$el.prepend('<div class="staff-banner">' + gettext('staff') + '</div>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.addClass("community-ta")
@$el.prepend('<div class="community-ta-banner">' + gettext('Community TA') + '</div>')
edit: (event) ->
@trigger "response:edit", event
_delete: (event) ->
@trigger "response:_delete", event
toggleEndorse: (event) ->
event.preventDefault()
if not @model.can('can_endorse')
return
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
@model.set('endorsed', not endorsed)
@trigger "comment:endorse", not endorsed
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderVote()
@renderFlagged()

View File

@@ -1,6 +1,7 @@
if Backbone?
class @ThreadResponseView extends DiscussionContentView
tagName: "li"
className: "forum-response"
events:
"click .discussion-submit-comment": "submitComment"
@@ -9,7 +10,8 @@ if Backbone?
$: (selector) ->
@$el.find(selector)
initialize: ->
initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView()
renderTemplate: ->
@@ -65,6 +67,15 @@ if Backbone?
collectComments(child)
@model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null)
if @collapseComments && comments.length
@$(".comments").hide()
@$(".action-show-comments").on("click", (event) =>
event.preventDefault()
@$(".action-show-comments").hide()
@$(".comments").show()
)
else
@$(".action-show-comments").hide()
renderComment: (comment) =>
comment.set('thread', @model.get('thread'))
@@ -155,6 +166,7 @@ if Backbone?
@showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () ->
@renderSubView(@showView)

View File

@@ -91,75 +91,7 @@ window.parseQueryString = function(queryString) {
return parameters
};
// Check if the user recently enrolled in a course by looking at a referral URL
window.checkRecentEnrollment = function(referrer) {
var enrolledIn = null;
// Check if the referrer URL contains a query string
if (referrer.indexOf("?") > -1) {
referrerQueryString = referrer.split("?")[1];
} else {
referrerQueryString = "";
}
if (referrerQueryString != "") {
// Convert a non-empty query string into a key/value object
var referrerParameters = window.parseQueryString(referrerQueryString);
if ("course_id" in referrerParameters && "enrollment_action" in referrerParameters) {
if (referrerParameters.enrollment_action == "enroll") {
enrolledIn = referrerParameters.course_id;
}
}
}
return enrolledIn
};
window.assessUserSignIn = function(parameters, userID, email, username) {
// Check if the user has logged in to enroll in a course - designed for when "Register" button registers users on click (currently, this could indicate a course registration when there may not have yet been one)
var enrolledIn = window.checkRecentEnrollment(document.referrer);
// Check if the user has just registered
if (parameters.signin == "initial") {
window.trackAccountRegistration(enrolledIn, userID, email, username);
} else {
window.trackReturningUserSignIn(enrolledIn, userID, email, username);
}
};
window.trackAccountRegistration = function(enrolledIn, userID, email, username) {
// Alias the user's anonymous history with the user's new identity (for Mixpanel)
analytics.alias(userID);
// Map the user's activity to their newly assigned ID
analytics.identify(userID, {
email: email,
username: username
});
// Track the user's account creation
analytics.track("edx.bi.user.account.registered", {
category: "conversion",
label: enrolledIn != null ? enrolledIn : "none"
});
};
window.trackReturningUserSignIn = function(enrolledIn, userID, email, username) {
// Map the user's activity to their assigned ID
analytics.identify(userID, {
email: email,
username: username
});
// Track the user's sign in
analytics.track("edx.bi.user.account.authenticated", {
category: "conversion",
label: enrolledIn != null ? enrolledIn : "none"
});
};
window.identifyUser = function(userID, email, username) {
// If the signin parameter isn't present but the query string is non-empty, map the user's activity to their assigned ID
analytics.identify(userID, {
email: email,
username: username

View File

@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory):
class Thread(ContentFactory):
thread_type = "discussion"
anonymous = False
anonymous_to_peers = False
comments_count = 0
@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture):
def addResponse(self, response, comments=[]):
response['children'] = comments
self.thread.setdefault('children', []).append(response)
if self.thread["thread_type"] == "discussion":
responseListAttr = "children"
elif response["endorsed"]:
responseListAttr = "endorsed_responses"
else:
responseListAttr = "non_endorsed_responses"
self.thread.setdefault(responseListAttr, []).append(response)
self.thread['comments_count'] += len(comments) + 1
def _get_comment_map(self):

View File

@@ -1,3 +1,5 @@
from contextlib import contextmanager
from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise
@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
query = self._find_within(selector)
return query.present and query.visible
@contextmanager
def _secondary_action_menu_open(self, ancestor_selector):
"""
Given the selector for an ancestor of a secondary menu, return a context
manager that will open and close the menu
"""
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu opened"
).fulfill()
yield
if self._is_element_visible(ancestor_selector + " .actions-dropdown"):
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: not self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu closed"
).fulfill()
def get_response_total_text(self):
"""Returns the response count text, or None if not present"""
return self._get_element_text(".response-count")
@@ -89,10 +110,23 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_response_edit(self, response_id):
"""Click the edit button for the response, loading the editing view"""
self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click()
with self._secondary_action_menu_open(".response_{} .discussion-response".format(response_id)):
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"
).fulfill()
def is_show_comments_visible(self, response_id):
"""Returns true if the "show comments" link is visible for a response"""
return self._is_element_visible(".response_{} .action-show-comments".format(response_id))
def show_comments(self, response_id):
"""Click the "show comments" link for a response"""
self._find_within(".response_{} .action-show-comments".format(response_id)).first.click()
EmptyPromise(
lambda: self.is_response_editor_visible(response_id),
"Response edit started"
lambda: self._is_element_visible(".response_{} .comments".format(response_id)),
"Comments shown"
).fulfill()
def is_add_comment_visible(self, response_id):
@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_deletable(self, comment_id):
"""Returns true if the delete comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} div.action-delete".format(comment_id))
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-delete".format(comment_id))
def delete_comment(self, comment_id):
with self.handle_alert():
self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click()
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-delete".format(comment_id)).first.click()
EmptyPromise(
lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed"
@@ -120,7 +156,8 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_editable(self, comment_id):
"""Returns true if the edit comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} .action-edit".format(comment_id))
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-edit".format(comment_id))
def is_comment_editor_visible(self, comment_id):
"""Returns true if the comment editor is present, false otherwise"""
@@ -132,15 +169,16 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
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._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
EmptyPromise(
lambda: (
self.is_comment_editor_visible(comment_id) and
not self.is_comment_visible(comment_id) and
self._get_comment_editor_value(comment_id) == old_body
),
"Comment edit started"
).fulfill()
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
EmptyPromise(
lambda: (
self.is_comment_editor_visible(comment_id) and
not self.is_comment_visible(comment_id) and
self._get_comment_editor_value(comment_id) == old_body
),
"Comment edit started"
).fulfill()
def set_comment_editor_value(self, comment_id, new_body):
"""Replace the contents of the comment editor"""
@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage):
def expand(self):
"""Clicks the link to expand the thread"""
self._find_within(".expand-post").first.click()
self._find_within(".forum-thread-expand").first.click()
EmptyPromise(
lambda: bool(self.get_response_total_text()),
"Thread expanded"

View File

@@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati
self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201
self.thread_page.visit()
def test_marked_answer_comments(self):
thread_id = "test_thread_{}".format(uuid4().hex)
response_id = "test_response_{}".format(uuid4().hex)
comment_id = "test_comment_{}".format(uuid4().hex)
thread_fixture = SingleThreadViewFixture(
Thread(id=thread_id, commentable_id=self.discussion_id, thread_type="question")
)
thread_fixture.addResponse(
Response(id=response_id, endorsed=True),
[Comment(id=comment_id)]
)
thread_fixture.push()
self.setup_thread_page(thread_id)
self.assertFalse(self.thread_page.is_comment_visible(comment_id))
self.assertFalse(self.thread_page.is_add_comment_visible(response_id))
self.assertTrue(self.thread_page.is_show_comments_visible(response_id))
self.thread_page.show_comments(response_id)
self.assertTrue(self.thread_page.is_comment_visible(comment_id))
self.assertTrue(self.thread_page.is_add_comment_visible(response_id))
self.assertFalse(self.thread_page.is_show_comments_visible(response_id))
@attr('shard_1')
class DiscussionCommentDeletionTest(UniqueCourseTest):