Commit 880673b9 by Calen Pennington

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

parents 4fcc59b3 c7d41833
...@@ -77,32 +77,6 @@ def marketing_link_context_processor(request): ...@@ -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'): def render_to_string(template_name, dictionary, context=None, namespace='main'):
# see if there is an override template defined in the microsite # see if there is an override template defined in the microsite
......
from mock import patch, Mock from mock import patch, Mock
import unittest import unittest
import ddt
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
...@@ -11,16 +10,11 @@ from django.test.client import RequestFactory ...@@ -11,16 +10,11 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import edxmako.middleware import edxmako.middleware
from edxmako import add_lookup, LOOKUP from edxmako import add_lookup, LOOKUP
from edxmako.shortcuts import ( from edxmako.shortcuts import marketing_link, render_to_string
marketing_link,
render_to_string,
header_footer_context_processor,
open_source_footer_context_processor
)
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
@ddt.ddt
class ShortcutsTests(UrlResetMixin, TestCase): class ShortcutsTests(UrlResetMixin, TestCase):
""" """
Test the edxmako shortcuts file Test the edxmako shortcuts file
...@@ -40,26 +34,6 @@ class ShortcutsTests(UrlResetMixin, TestCase): ...@@ -40,26 +34,6 @@ class ShortcutsTests(UrlResetMixin, TestCase):
link = marketing_link('ABOUT') link = marketing_link('ABOUT')
self.assertEquals(link, expected_link) 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): class AddLookupTests(TestCase):
""" """
......
...@@ -46,7 +46,8 @@ from student.models import ( ...@@ -46,7 +46,8 @@ from student.models import (
Registration, UserProfile, PendingNameChange, Registration, UserProfile, PendingNameChange,
PendingEmailChange, CourseEnrollment, unique_id_for_user, PendingEmailChange, CourseEnrollment, unique_id_for_user,
CourseEnrollmentAllowed, UserStanding, LoginFailures, CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory, UserSignupSource create_comments_service_user, PasswordHistory, UserSignupSource,
anonymous_id_for_user
) )
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
...@@ -92,6 +93,9 @@ from util.password_policy_validators import ( ...@@ -92,6 +93,9 @@ from util.password_policy_validators import (
from third_party_auth import pipeline, provider from third_party_auth import pipeline, provider
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
import analytics
from eventtracking import tracker
log = logging.getLogger("edx.student") log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
...@@ -381,6 +385,10 @@ def register_user(request, extra_context=None): ...@@ -381,6 +385,10 @@ def register_user(request, extra_context=None):
'username': '', '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: if extra_context is not None:
context.update(extra_context) context.update(extra_context)
...@@ -951,6 +959,31 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un ...@@ -951,6 +959,31 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
if LoginFailures.is_feature_enabled(): if LoginFailures.is_feature_enabled():
LoginFailures.clear_lockout_counter(user) 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: if user is not None and user.is_active:
try: try:
# We do not log here, because we have a handler registered # 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 ...@@ -1398,6 +1431,33 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
(user, profile, registration) = ret (user, profile, registration) = ret
dog_stats_api.increment("common.student.account_created") 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) create_comments_service_user(user)
context = { context = {
......
...@@ -4,7 +4,7 @@ Loaded by Django's settings mechanism. Consequently, this module must not ...@@ -4,7 +4,7 @@ Loaded by Django's settings mechanism. Consequently, this module must not
invoke the Django armature. invoke the Django armature.
""" """
from social.backends import google, linkedin from social.backends import google, linkedin, facebook
_DEFAULT_ICON_CLASS = 'icon-signin' _DEFAULT_ICON_CLASS = 'icon-signin'
...@@ -150,6 +150,26 @@ class LinkedInOauth2(BaseProvider): ...@@ -150,6 +150,26 @@ class LinkedInOauth2(BaseProvider):
return provider_details.get('fullname') 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): class Registry(object):
"""Singleton registry of third-party auth providers. """Singleton registry of third-party auth providers.
......
...@@ -282,7 +282,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -282,7 +282,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_register_response_before_pipeline_looks_correct(self, response): def assert_register_response_before_pipeline_looks_correct(self, response):
"""Asserts a GET of /register not in the pipeline looks correct.""" """Asserts a GET of /register not in the pipeline looks correct."""
self.assertEqual(200, response.status_code) 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) self.assert_signin_button_looks_functional(response.content, pipeline.AUTH_ENTRY_REGISTER)
def assert_signin_button_looks_functional(self, content, auth_entry): def assert_signin_button_looks_functional(self, content, auth_entry):
......
...@@ -41,11 +41,11 @@ describe 'All Content', -> ...@@ -41,11 +41,11 @@ describe 'All Content', ->
it 'can update info', -> it 'can update info', ->
@content.updateInfo { @content.updateInfo {
ability: 'can_endorse', ability: {'can_edit': true},
voted: true, voted: true,
subscribed: 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 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true expect(@content.get 'subscribed').toEqual true
...@@ -77,3 +77,39 @@ describe 'All Content', -> ...@@ -77,3 +77,39 @@ describe 'All Content', ->
myComments = new Comments myComments = new Comments
myComments.add @comment1 myComments.add @comment1
expect(myComments.find('123')).toBe @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)
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})
describe "DiscussionContentView", -> describe "DiscussionContentView", ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
setFixtures( DiscussionSpecHelper.setUnderscoreFixtures()
"""
<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>
"""
)
@threadData = { @threadData = {
id: '01234567', id: '01234567',
...@@ -35,7 +16,8 @@ describe "DiscussionContentView", -> ...@@ -35,7 +16,8 @@ describe "DiscussionContentView", ->
} }
@thread = new Thread(@threadData) @thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread }) @view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post')) @view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', -> it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist expect($('#jasmine-fixtures')).toExist
...@@ -59,15 +41,3 @@ describe "DiscussionContentView", -> ...@@ -59,15 +41,3 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array) @thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse() @thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual [] 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)
describe "DiscussionThreadShowView", -> describe "DiscussionThreadShowView", ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
setFixtures( DiscussionSpecHelper.setUnderscoreFixtures()
"""
<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>
"""
)
@user = DiscussionUtil.getUser()
@threadData = { @threadData = {
id: "dummy", id: "dummy",
user_id: user.id, user_id: @user.id,
username: @user.get('username'),
course_id: $$course_id, course_id: $$course_id,
title: "dummy title",
body: "this is a thread", body: "this is a thread",
created_at: "2013-04-03T20:08:39Z", created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [], 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) @thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread }) @view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($(".discussion-post")) @view.setElement($("#fixture-element"))
@spyOn(@view, "convertMath")
it "renders the vote correctly", -> describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread) DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly", -> it "votes correctly via click", ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true) DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click"))
it "unvotes correctly", -> it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true) DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it 'toggles the vote correctly', -> describe "pinning", ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
it "vote button activates on appropriate events", -> expectPinnedRendered = (view, model) ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view) 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())
describe "renderPinned", -> it "renders the pinned state correctly", ->
describe "for an unpinned thread", -> @view.render()
it "renders correctly when pinning is allowed", -> expectPinnedRendered(@view, @thread)
@thread.set('pinned', false)
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', true)
@view.render()
expectPinnedRendered(@view, @thread)
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}}) @thread.updateInfo({ability: {can_openclose: true}})
@view.renderPinned() @view.render()
pinElem = @view.$(".discussion-pin") expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
expect(pinElem.length).toEqual(1)
expect(pinElem).not.toHaveClass("pinned") it "handles events correctly", ->
expect(pinElem).toHaveClass("notpinned") @view.render()
expect(pinElem.find(".pin-label")).toHaveHtml("Pin Thread") DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
expect(pinElem).not.toHaveAttr("data-tooltip")
expect(pinElem).toHaveAttr("aria-pressed", "false") describe "labels", ->
# If pinning is not allowed, the pinning UI is not present, so no expectOneElement = (view, selector, visible=true) =>
# test is needed view.render()
elements = view.$el.find(selector)
describe "for a pinned thread", -> 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 -> beforeEach ->
@thread.set("pinned", true) @thread.set('user_url', 'test_user_url')
it "renders correctly when unpinning is allowed", -> checkUserLink = (element, is_ta, is_staff) ->
@thread.updateInfo({ability: {can_openclose: true}}) expect(element.find('a.username').length).toEqual(1)
@view.renderPinned() expect(element.find('a.username').text()).toEqual('test_user')
pinElem = @view.$(".discussion-pin") expect(element.find('a.username').attr('href')).toEqual('test_user_url')
expect(pinElem.length).toEqual(1) expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(pinElem).toHaveClass("pinned") expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
expect(pinElem).not.toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned<span class='sr'>, click to unpin</span>") it "renders correctly for a student-authored thread", ->
expect(pinElem).toHaveAttr("data-tooltip", "Click to unpin") $el = $('#fixture-element').html(@view.getAuthorDisplay())
expect(pinElem).toHaveAttr("aria-pressed", "true") checkUserLink($el, false, false)
it "renders correctly when unpinning is not allowed", -> it "renders correctly for a community TA-authored thread", ->
@view.renderPinned() @thread.set('community_ta_authored', true)
pinElem = @view.$(".discussion-pin") $el = $('#fixture-element').html(@view.getAuthorDisplay())
expect(pinElem.length).toEqual(1) checkUserLink($el, true, false)
expect(pinElem).toHaveClass("pinned")
expect(pinElem).not.toHaveClass("notpinned") it "renders correctly for a staff-authored thread", ->
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned") @thread.set('staff_authored', true)
expect(pinElem).not.toHaveAttr("data-tooltip") $el = $('#fixture-element').html(@view.getAuthorDisplay())
expect(pinElem).not.toHaveAttr("aria-pressed") checkUserLink($el, false, true)
it "renders correctly for an anonymously-authored thread", ->
it "pinning button activates on appropriate events", -> @thread.set('username', null)
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".admin-pin") $el = $('#fixture-element').html(@view.getAuthorDisplay())
expect($el.find('a.username').length).toEqual(0)
expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)
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()
class @DiscussionViewSpecHelper class @DiscussionViewSpecHelper
@expectVoteRendered = (view, voted) -> @makeThreadWithProps = (props) ->
button = view.$el.find(".vote-btn") # Minimal set of properties necessary for rendering
if voted thread = {
expect(button.hasClass("is-cast")).toBe(true) id: "dummy_id",
expect(button.attr("aria-pressed")).toEqual("true") thread_type: "discussion",
expect(button.attr("data-tooltip")).toEqual("remove vote") pinned: false,
expect(button.text()).toEqual("43 votes (click to remove your vote)") endorsed: false,
else votes: {up_count: '0'},
expect(button.hasClass("is-cast")).toBe(false) unread_comments_count: 0,
expect(button.attr("aria-pressed")).toEqual("false") comments_count: 0,
expect(button.attr("data-tooltip")).toEqual("vote") abuse_flaggers: [],
expect(button.text()).toEqual("42 votes (click to vote)") 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) -> @checkRenderVote = (view, model) ->
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model) window.user.vote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, true) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model) window.user.unvote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
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: ->}
)
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
@checkUnvote = (view, model, modelData, checkRendering) ->
window.user.vote(model)
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").andCallFake((params) => spyOn($, "ajax").andCallFake((params) =>
newModelData = {} expect(params.url.toString()).toEqual(expectedUrl)
$.extend(newModelData, modelData, {votes: {up_count: "42"}}) return deferred
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
) )
view.render()
view.unvote() view.$el.find(".action-vote").trigger(event)
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled() expect($.ajax).toHaveBeenCalled()
$.ajax.reset() deferred.resolve()
# Check idempotence @checkUpvote = (view, model, user, event) ->
view.unvote() expect(model.id in user.get('upvoted_ids')).toBe(false)
expect(window.user.voted(model)).toBe(false) initialVoteCount = model.get('votes').up_count
if checkRendering triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
DiscussionViewSpecHelper.expectVoteRendered(view, false) expect(model.id in user.get('upvoted_ids')).toBe(true)
expect($.ajax).toHaveBeenCalled() expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
@checkToggleVote = (view, model) -> @checkUnvote = (view, model, user, event) ->
event = {preventDefault: ->} user.vote(model)
spyOn(event, "preventDefault") expect(model.id in user.get('upvoted_ids')).toBe(true)
spyOn(view, "vote").andCallFake(() -> window.user.vote(model)) initialVoteCount = model.get('votes').up_count
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model)) triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(user.get('upvoted_ids')).toEqual([])
expect(window.user.voted(model)).toBe(false) expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
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)
@checkButtonEvents = (view, viewFunc, buttonSelector) -> @checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc) spy = spyOn(view, viewFunc)
...@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper ...@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper
expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) -> @checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".vote-btn") @checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) -> @setNextResponseContent = (content) ->
$.ajax.andCallFake( $.ajax.andCallFake(
......
...@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', -> ...@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in # set up the container for the response to go in
setFixtures """ DiscussionSpecHelper.setUnderscoreFixtures()
<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>
"""
# set up a model for a new Comment # set up a model for a new Comment
@comment = new Comment { @comment = new Comment {
...@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', -> ...@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', ->
beforeEach -> beforeEach ->
spyOn(@view, 'renderAttrs') 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', -> it 'can be flagged for abuse', ->
@comment.flagAbuse() @comment.flagAbuse()
...@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', -> ...@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', ->
@view.bind "comment:edit", triggerTarget @view.bind "comment:edit", triggerTarget
@view.edit() @view.edit()
expect(triggerTarget).toHaveBeenCalled() 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')
...@@ -10,19 +10,9 @@ describe 'ResponseCommentView', -> ...@@ -10,19 +10,9 @@ describe 'ResponseCommentView', ->
abuse_flaggers: ['123'] abuse_flaggers: ['123']
roles: ['Student'] roles: ['Student']
} }
setFixtures """ DiscussionSpecHelper.setUnderscoreFixtures()
<script id="response-comment-show-template" type="text/template">
<div id="response-comment-show-div"/> @view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
</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") })
spyOn(ResponseCommentShowView.prototype, "convertMath") spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor") spyOn(DiscussionUtil, "makeWmdEditor")
@view.render() @view.render()
...@@ -95,8 +85,7 @@ describe 'ResponseCommentView', -> ...@@ -95,8 +85,7 @@ describe 'ResponseCommentView', ->
expect(@view._delete).toHaveBeenCalled() expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", makeEventSpy() @view.showView.trigger "comment:edit", makeEventSpy()
expect(@view.edit).toHaveBeenCalled() expect(@view.edit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(1) expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
expect(@view.$("#response-comment-edit-div").length).toEqual(0)
describe 'renderEditView', -> describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', -> it 'renders the edit view, removes the show view, and registers event handlers', ->
...@@ -107,8 +96,7 @@ describe 'ResponseCommentView', -> ...@@ -107,8 +96,7 @@ describe 'ResponseCommentView', ->
expect(@view.update).toHaveBeenCalled() expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", makeEventSpy() @view.editView.trigger "comment:cancel_edit", makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled() expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(0) expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
expect(@view.$("#response-comment-edit-div").length).toEqual(1)
describe 'edit', -> describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', -> it 'triggers the appropriate event and switches to the edit view', ->
...@@ -135,6 +123,8 @@ describe 'ResponseCommentView', -> ...@@ -135,6 +123,8 @@ describe 'ResponseCommentView', ->
describe 'update', -> describe 'update', ->
beforeEach -> beforeEach ->
@updatedBody = "updated body" @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) @view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit') spyOn(@view, 'cancelEdit')
spyOn($, "ajax").andCallFake( spyOn($, "ajax").andCallFake(
......
describe 'ThreadResponseView', -> describe 'ThreadResponseView', ->
beforeEach -> beforeEach ->
setFixtures """ DiscussionSpecHelper.setUpGlobals()
<script id="thread-response-template" type="text/template"> DiscussionSpecHelper.setUnderscoreFixtures()
<div/>
</script>
<div id="thread-response-fixture"/>
"""
@response = new Comment { @response = new Comment {
children: [{}, {}] children: [{}, {}]
} }
@view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")}) @view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render") spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render") spyOn(ResponseCommentView.prototype, "render")
describe 'renderComments', -> 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', -> it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit # Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView() @view.createEditView()
......
...@@ -9,7 +9,6 @@ if Backbone? ...@@ -9,7 +9,6 @@ if Backbone?
actions: actions:
editable: '.admin-edit' editable: '.admin-edit'
can_reply: '.discussion-reply' can_reply: '.discussion-reply'
can_endorse: '.admin-endorse'
can_delete: '.admin-delete' can_delete: '.admin-delete'
can_openclose: '.admin-openclose' can_openclose: '.admin-openclose'
...@@ -21,6 +20,9 @@ if Backbone? ...@@ -21,6 +20,9 @@ if Backbone?
can: (action) -> can: (action) ->
(@get('ability') || {})[action] (@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) -> updateInfo: (info) ->
if info if info
@set('ability', info.ability) @set('ability', info.ability)
...@@ -106,13 +108,21 @@ if Backbone? ...@@ -106,13 +108,21 @@ if Backbone?
@get("abuse_flaggers").pop(window.user.get('id')) @get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @ @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: -> vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1 @incrementVote(1)
@trigger "change", @
unvote: -> unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1 @incrementVote(-1)
@trigger "change", @
class @Thread extends @Content class @Thread extends @Content
urlMappers: urlMappers:
...@@ -187,6 +197,13 @@ if Backbone? ...@@ -187,6 +197,13 @@ if Backbone?
count += comment.getCommentsCount() + 1 count += comment.getCommentsCount() + 1
count 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 class @Comments extends Backbone.Collection
model: Comment model: Comment
......
...@@ -34,6 +34,8 @@ if Backbone? ...@@ -34,6 +34,8 @@ if Backbone?
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 } data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode switch mode
when 'search' when 'search'
url = DiscussionUtil.urlFor 'search' url = DiscussionUtil.urlFor 'search'
...@@ -43,9 +45,6 @@ if Backbone? ...@@ -43,9 +45,6 @@ if Backbone?
data['commentable_ids'] = options.commentable_ids data['commentable_ids'] = options.commentable_ids
when 'all' when 'all'
url = DiscussionUtil.urlFor 'threads' url = DiscussionUtil.urlFor 'threads'
when 'flagged'
data['flagged'] = true
url = DiscussionUtil.urlFor 'search'
when 'followed' when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id'] if options['group_id']
......
class @DiscussionFilter 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) -> @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() query = $(e.target).val()
$items = $drop.find('a') $items = $drop.find('.topic-menu-item')
if(query.length == 0) if(query.length == 0)
$items.removeClass('hidden') $items.removeClass('hidden')
...@@ -10,19 +15,14 @@ class @DiscussionFilter ...@@ -10,19 +15,14 @@ class @DiscussionFilter
$items.addClass('hidden') $items.addClass('hidden')
$items.each (i) -> $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(' ')
if(thisText.toLowerCase().search(query.toLowerCase()) == -1) path = $(this).parents(".topic-menu-item").andSelf()
test = false pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ").toLowerCase()
if(test) if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
$(this).removeClass('hidden') $(this).removeClass('hidden')
# show children # show children
$(this).parent().find('a').removeClass('hidden'); $(this).find('.topic-menu-item').removeClass('hidden');
# show parents # show parents
$(this).parents('ul').siblings('a').removeClass('hidden'); $(this).parents('.topic-menu-item').removeClass('hidden');
...@@ -7,7 +7,7 @@ if Backbone? ...@@ -7,7 +7,7 @@ if Backbone?
"click .new-post-btn": "toggleNewPost" "click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn": "keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost) (event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .new-post-cancel": "hideNewPost" "click .cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage" "click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination") paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
...@@ -101,7 +101,7 @@ if Backbone? ...@@ -101,7 +101,7 @@ if Backbone?
@newPostForm = $('.new-post-article') @newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) -> @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() _.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info) DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView( @newPostView = new NewPostView(
...@@ -124,7 +124,7 @@ if Backbone? ...@@ -124,7 +124,7 @@ if Backbone?
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1? # TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>") article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article) @$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread threadView = new DiscussionThreadView el: article, model: thread, mode: "inline"
threadView.render() threadView.render()
@threadviews.unshift threadView @threadviews.unshift threadView
......
...@@ -25,7 +25,7 @@ if Backbone? ...@@ -25,7 +25,7 @@ if Backbone?
@newPostView.render() @newPostView.render()
$('.new-post-btn').bind "click", @showNewPost $('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost) $('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
$('.new-post-cancel').bind "click", @hideNewPost @newPostView.$('.cancel').bind "click", @hideNewPost
allThreads: -> allThreads: ->
@nav.updateSidebar() @nav.updateSidebar()
...@@ -45,8 +45,12 @@ if Backbone? ...@@ -45,8 +45,12 @@ if Backbone?
if(@main) if(@main)
@main.cleanup() @main.cleanup()
@main.undelegateEvents() @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.render()
@main.on "thread:responses:rendered", => @main.on "thread:responses:rendered", =>
@nav.updateSidebar() @nav.updateSidebar()
...@@ -59,8 +63,17 @@ if Backbone? ...@@ -59,8 +63,17 @@ if Backbone?
@navigate("", trigger: true) @navigate("", trigger: true)
showNewPost: (event) => showNewPost: (event) =>
@newPost.slideDown(300) $('.forum-content').fadeOut(
duration: 200
complete: =>
@newPost.fadeIn(200)
$('.new-post-title').focus() $('.new-post-title').focus()
)
hideNewPost: (event) => hideNewPost: (event) =>
@newPost.slideUp(300) @newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200)
)
...@@ -21,15 +21,14 @@ class @DiscussionUtil ...@@ -21,15 +21,14 @@ class @DiscussionUtil
@setUser: (user) -> @setUser: (user) ->
@user = user @user = user
@getUser: () ->
@user
@loadRoles: (roles)-> @loadRoles: (roles)->
@roleIds = roles @roleIds = roles
@loadFlagModerator: (what)->
@isFlagModerator = ((what=="True") or (what == 1))
@loadRolesFromContainer: -> @loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles")) @loadRoles($("#discussion-container").data("roles"))
@loadFlagModerator($("#discussion-container").data("flag-moderator"))
@isStaff: (user_id) -> @isStaff: (user_id) ->
user_id ?= @user?.id user_id ?= @user?.id
...@@ -41,6 +40,9 @@ class @DiscussionUtil ...@@ -41,6 +40,9 @@ class @DiscussionUtil
ta = _.union(@roleIds['Community TA']) ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id)) _.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) -> @bulkUpdateContentInfo: (infos) ->
for id, info of infos for id, info of infos
Content.getContent(id).updateInfo(info) Content.getContent(id).updateInfo(info)
...@@ -159,6 +161,13 @@ class @DiscussionUtil ...@@ -159,6 +161,13 @@ class @DiscussionUtil
params["$loading"].loaded() params["$loading"].loaded()
return request 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) -> @bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ') [event, selector] = eventSelector.split(' ')
...@@ -167,7 +176,7 @@ class @DiscussionUtil ...@@ -167,7 +176,7 @@ class @DiscussionUtil
@formErrorHandler: (errorsField) -> @formErrorHandler: (errorsField) ->
(xhr, textStatus, error) -> (xhr, textStatus, error) ->
makeErrorElem = (message) -> makeErrorElem = (message) ->
$("<li>").addClass("new-post-form-error").html(message) $("<li>").addClass("post-error").html(message)
errorsField.empty().show() errorsField.empty().show()
if xhr.status == 400 if xhr.status == 400
response = JSON.parse(xhr.responseText) response = JSON.parse(xhr.responseText)
......
...@@ -10,6 +10,7 @@ if Backbone? ...@@ -10,6 +10,7 @@ if Backbone?
"change .forum-nav-sort-control": "sortThreads" "change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected" "click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages" "click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort" "change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: -> initialize: ->
...@@ -75,7 +76,7 @@ if Backbone? ...@@ -75,7 +76,7 @@ if Backbone?
#TODO fix this entire chain of events #TODO fix this entire chain of events
addAndSelectThread: (thread) => addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id") 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)) @setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, => @retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id') @trigger "thread:created", thread.get('id')
...@@ -173,7 +174,7 @@ if Backbone? ...@@ -173,7 +174,7 @@ if Backbone?
loadingElem = loadMoreElem.find(".forum-nav-loading") loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem) DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus() loadingElem.focus()
options = {} options = {filter: @filter}
switch @mode switch @mode
when 'search' when 'search'
options.search_text = @current_search options.search_text = @current_search
...@@ -242,7 +243,7 @@ if Backbone? ...@@ -242,7 +243,7 @@ if Backbone?
goHome: -> goHome: ->
@template = _.template($("#discussion-home").html()) @template = _.template($("#discussion-home").html())
$(".discussion-column").html(@template) $(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active") $(".forum-nav-thread-list a").removeClass("is-active")
$("input.email-setting").bind "click", @updateEmailNotifications $("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id")) url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
...@@ -363,26 +364,24 @@ if Backbone? ...@@ -363,26 +364,24 @@ if Backbone?
@discussionIds = "" @discussionIds = ""
@$('.forum-nav-filter-cohort').show() @$('.forum-nav-filter-cohort').show()
@retrieveAllThreads() @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") else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed() @retrieveFollowed()
@$('.forum-nav-filter-cohort').hide() @$('.forum-nav-filter-cohort').hide()
else else
allItems = item.find(".forum-nav-browse-menu-item").andSelf() allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map( discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id").id (i, elem) -> $(elem).data("discussion-id")
).get() ).get()
@retrieveDiscussions(discussionIds) @retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true) @$(".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() @group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@collection.current_page = 0 @retrieveFirstPage()
@collection.reset()
@loadMorePages(event)
retrieveDiscussion: (discussion_id, callback=null) -> retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id) url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
...@@ -413,12 +412,6 @@ if Backbone? ...@@ -413,12 +412,6 @@ if Backbone?
@collection.reset() @collection.reset()
@loadMorePages(event) @loadMorePages(event)
retrieveFlaggedThreads: (event)->
@collection.current_page = 0
@collection.reset()
@mode = 'flagged'
@loadMorePages(event)
sortThreads: (event) -> sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val()) @displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
...@@ -434,6 +427,7 @@ if Backbone? ...@@ -434,6 +427,7 @@ if Backbone?
searchFor: (text) -> searchFor: (text) ->
@clearSearchAlerts() @clearSearchAlerts()
@clearFilters()
@mode = 'search' @mode = 'search'
@current_search = text @current_search = text
url = DiscussionUtil.urlFor("search") url = DiscussionUtil.urlFor("search")
...@@ -499,6 +493,11 @@ if Backbone? ...@@ -499,6 +493,11 @@ if Backbone?
clearSearch: -> clearSearch: ->
@$(".forum-nav-search-input").val("") @$(".forum-nav-search-input").val("")
@current_search = "" @current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () => retrieveFollowed: () =>
@mode = 'followed' @mode = 'followed'
......
if Backbone? if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
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: ->
super() 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: -> renderTemplate: ->
@template = _.template($("#thread-show-template").html()) @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: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
@renderVote()
@renderFlagged()
@renderPinned()
@renderAttrs() @renderAttrs()
@$("span.timeago").timeago() @$("span.timeago").timeago()
@convertMath() @convertMath()
...@@ -44,60 +29,6 @@ if Backbone? ...@@ -44,60 +29,6 @@ if Backbone?
@highlight @$("h1,h3") @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: -> convertMath: ->
element = @$(".post-body") element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
...@@ -109,74 +40,6 @@ if Backbone? ...@@ -109,74 +40,6 @@ if Backbone?
_delete: (event) -> _delete: (event) ->
@trigger "thread:_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) -> highlight: (el) ->
if el.html() if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>")) 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)
...@@ -7,14 +7,25 @@ if Backbone? ...@@ -7,14 +7,25 @@ if Backbone?
events: events:
"click .discussion-submit-post": "submitComment" "click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse" "click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) -> $: (selector) ->
@$el.find(selector) @$el.find(selector)
initialize: -> isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super() 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() @createShowView()
@responses = new Comments() @responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-template").html()) @template = _.template($("#thread-template").html())
...@@ -22,7 +33,6 @@ if Backbone? ...@@ -22,7 +33,6 @@ if Backbone?
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@initLocal()
@delegateEvents() @delegateEvents()
@renderShowView() @renderShowView()
...@@ -31,11 +41,53 @@ if Backbone? ...@@ -31,11 +41,53 @@ if Backbone?
@$("span.timeago").timeago() @$("span.timeago").timeago()
@makeWmdEditor "reply-body" @makeWmdEditor "reply-body"
@renderAddResponseButton() @renderAddResponseButton()
@responses.on("add", @renderResponse) @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 # Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error # utils.coffee before safeAjax is invoked, which results in an error
setTimeout((=> @loadInitialResponses()), 100) 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: -> cleanup: ->
if @responsesRequest? if @responsesRequest?
...@@ -54,9 +106,20 @@ if Backbone? ...@@ -54,9 +106,20 @@ if Backbone?
@responseRequest = null @responseRequest = null
success: (data, textStatus, xhr) => success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info']) Content.loadContentInfos(data['annotated_content_info'])
@responses.add(data['content']['children']) if @isQuestion()
@renderResponseCountAndPagination(data['content']['resp_total']) @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" @trigger "thread:responses:rendered"
@loadedResponses = true
error: (xhr) => error: (xhr) =>
if xhr.status == 404 if xhr.status == 404
DiscussionUtil.discussionAlert( DiscussionUtil.discussionAlert(
...@@ -75,16 +138,24 @@ if Backbone? ...@@ -75,16 +138,24 @@ if Backbone?
) )
loadInitialResponses: () -> loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true) @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) => renderResponseCountAndPagination: (responseTotal) =>
@$el.find(".response-count").html( if @isQuestion() && @markedAnswers.length != 0
interpolate( responseCountFormat = ngettext(
ngettext( "%(numResponses)s other response",
"%(numResponses)s other responses",
responseTotal
)
else
responseCountFormat = ngettext(
"%(numResponses)s response", "%(numResponses)s response",
"%(numResponses)s responses", "%(numResponses)s responses",
responseTotal responseTotal
), )
@$el.find(".response-count").html(
interpolate(
responseCountFormat,
{numResponses: responseTotal}, {numResponses: responseTotal},
true true
) )
...@@ -126,17 +197,17 @@ if Backbone? ...@@ -126,17 +197,17 @@ if Backbone?
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton)) loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton) responsePagination.append(loadMoreButton)
renderResponse: (response) => renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model) response.set('thread', @model)
view = new ThreadResponseView(model: response) view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread view.on "comment:endorse", @endorseThread
view.render() view.render()
@$el.find(".responses").append(view.el) @$el.find(listSelector).append(view.el)
view.afterInsert() view.afterInsert()
renderAddResponseButton: -> renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show() @$el.find('div.add-response').show()
else else
@$el.find('div.add-response').hide() @$el.find('div.add-response').hide()
...@@ -150,9 +221,8 @@ if Backbone? ...@@ -150,9 +221,8 @@ if Backbone?
addComment: => addComment: =>
@model.comment() @model.comment()
endorseThread: (endorsed) => endorseThread: =>
is_endorsed = @$el.find(".is-endorsed").length @model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
@model.set 'endorsed', is_endorsed
submitComment: (event) -> submitComment: (event) ->
event.preventDefault() event.preventDefault()
...@@ -162,7 +232,7 @@ if Backbone? ...@@ -162,7 +232,7 @@ if Backbone?
@setWmdContent("reply-body", "") @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 = 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')) comment.set('thread', @model.get('thread'))
@renderResponse(comment) @renderResponseToList(comment, ".js-response-list")
@model.addComment() @model.addComment()
@renderAddResponseButton() @renderAddResponseButton()
...@@ -209,6 +279,7 @@ if Backbone? ...@@ -209,6 +279,7 @@ if Backbone?
@model.set @model.set
title: newTitle title: newTitle
body: newBody body: newBody
@model.unset("abbreviatedBody")
@createShowView() @createShowView()
@renderShowView() @renderShowView()
...@@ -232,9 +303,6 @@ if Backbone? ...@@ -232,9 +303,6 @@ if Backbone?
renderEditView: () -> renderEditView: () ->
@renderSubView(@editView) @renderSubView(@editView)
getShowViewClass: () ->
return DiscussionThreadShowView
createShowView: () -> createShowView: () ->
if @editView? if @editView?
...@@ -242,8 +310,7 @@ if Backbone? ...@@ -242,8 +310,7 @@ if Backbone?
@editView.$el.empty() @editView.$el.empty()
@editView = null @editView = null
showViewClass = @getShowViewClass() @showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView = new showViewClass(model: @model)
@showView.bind "thread:_delete", @_delete @showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit @showView.bind "thread:edit", @edit
......
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
if Backbone? if Backbone?
class @ResponseCommentShowView extends DiscussionContentView class @ResponseCommentShowView extends DiscussionContentShowView
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)
tagName: "li" 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: -> render: ->
@template = _.template($("#response-comment-show-template").html()) @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() @delegateEvents()
@renderAttrs() @renderAttrs()
@renderFlagged()
@markAsStaff()
@$el.find(".timeago").timeago() @$el.find(".timeago").timeago()
@convertMath() @convertMath()
@addReplyLink() @addReplyLink()
...@@ -52,31 +35,8 @@ if Backbone? ...@@ -52,31 +35,8 @@ if Backbone?
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text() body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]] 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) => _delete: (event) =>
@trigger "comment:_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) => edit: (event) =>
@trigger "comment:edit", event @trigger "comment:edit", event
if Backbone? if Backbone?
class @ThreadResponseShowView extends DiscussionContentView class @ThreadResponseShowView extends DiscussionContentShowView
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)
initialize: -> initialize: ->
super() super()
@model.on "change", @updateModelDetails @listenTo(@model, "change", @render)
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-response-show-template").html()) @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: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
@renderVote()
@renderAttrs() @renderAttrs()
@renderFlagged() @$el.find(".posted-details .timeago").timeago()
@$el.find(".posted-details").timeago()
@convertMath() @convertMath()
@markAsStaff()
@ @
convertMath: -> convertMath: ->
...@@ -39,54 +29,8 @@ if Backbone? ...@@ -39,54 +29,8 @@ if Backbone?
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]] 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) -> edit: (event) ->
@trigger "response:edit", event @trigger "response:edit", event
_delete: (event) -> _delete: (event) ->
@trigger "response:_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()
if Backbone? if Backbone?
class @ThreadResponseView extends DiscussionContentView class @ThreadResponseView extends DiscussionContentView
tagName: "li" tagName: "li"
className: "forum-response"
events: events:
"click .discussion-submit-comment": "submitComment" "click .discussion-submit-comment": "submitComment"
...@@ -9,7 +10,8 @@ if Backbone? ...@@ -9,7 +10,8 @@ if Backbone?
$: (selector) -> $: (selector) ->
@$el.find(selector) @$el.find(selector)
initialize: -> initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView() @createShowView()
renderTemplate: -> renderTemplate: ->
...@@ -65,6 +67,15 @@ if Backbone? ...@@ -65,6 +67,15 @@ if Backbone?
collectComments(child) collectComments(child)
@model.get('comments').each collectComments @model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null) 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) => renderComment: (comment) =>
comment.set('thread', @model.get('thread')) comment.set('thread', @model.get('thread'))
...@@ -155,6 +166,7 @@ if Backbone? ...@@ -155,6 +166,7 @@ if Backbone?
@showView = new ThreadResponseShowView(model: @model) @showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete @showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit @showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () -> renderShowView: () ->
@renderSubView(@showView) @renderSubView(@showView)
......
...@@ -91,75 +91,7 @@ window.parseQueryString = function(queryString) { ...@@ -91,75 +91,7 @@ window.parseQueryString = function(queryString) {
return parameters 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) { 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, { analytics.identify(userID, {
email: email, email: email,
username: username username: username
......
...@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory): ...@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory):
class Thread(ContentFactory): class Thread(ContentFactory):
thread_type = "discussion"
anonymous = False anonymous = False
anonymous_to_peers = False anonymous_to_peers = False
comments_count = 0 comments_count = 0
...@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture): ...@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture):
def addResponse(self, response, comments=[]): def addResponse(self, response, comments=[]):
response['children'] = 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 self.thread['comments_count'] += len(comments) + 1
def _get_comment_map(self): def _get_comment_map(self):
......
from contextlib import contextmanager
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
...@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
query = self._find_within(selector) query = self._find_within(selector)
return query.present and query.visible 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): def get_response_total_text(self):
"""Returns the response count text, or None if not present""" """Returns the response count text, or None if not present"""
return self._get_element_text(".response-count") return self._get_element_text(".response-count")
...@@ -89,12 +110,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -89,12 +110,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_response_edit(self, response_id): def start_response_edit(self, response_id):
"""Click the edit button for the response, loading the editing view""" """Click the edit button for the response, loading the editing view"""
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() self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click()
EmptyPromise( EmptyPromise(
lambda: self.is_response_editor_visible(response_id), lambda: self.is_response_editor_visible(response_id),
"Response edit started" "Response edit started"
).fulfill() ).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_element_visible(".response_{} .comments".format(response_id)),
"Comments shown"
).fulfill()
def is_add_comment_visible(self, response_id): def is_add_comment_visible(self, response_id):
"""Returns true if the "add comment" form is visible for a response""" """Returns true if the "add comment" form is visible for a response"""
return self._is_element_visible("#wmd-input-comment-body-{}".format(response_id)) return self._is_element_visible("#wmd-input-comment-body-{}".format(response_id))
...@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_deletable(self, comment_id): def is_comment_deletable(self, comment_id):
"""Returns true if the delete comment button is present, false otherwise""" """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): def delete_comment(self, comment_id):
with self.handle_alert(): 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( EmptyPromise(
lambda: not self.is_comment_visible(comment_id), lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed" "Deleted comment was removed"
...@@ -120,6 +156,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -120,6 +156,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_editable(self, comment_id): def is_comment_editable(self, comment_id):
"""Returns true if the edit comment button is present, false otherwise""" """Returns true if the edit comment button is present, false otherwise"""
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-edit".format(comment_id)) return self._is_element_visible("#comment_{} .action-edit".format(comment_id))
def is_comment_editor_visible(self, comment_id): def is_comment_editor_visible(self, comment_id):
...@@ -132,6 +169,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -132,6 +169,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_comment_edit(self, comment_id): def start_comment_edit(self, comment_id):
"""Click the edit button for the comment, loading the editing view""" """Click the edit button for the comment, loading the editing view"""
old_body = self.get_comment_body(comment_id) old_body = self.get_comment_body(comment_id)
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-edit".format(comment_id)).first.click() self._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
EmptyPromise( EmptyPromise(
lambda: ( lambda: (
...@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage): ...@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage):
def expand(self): def expand(self):
"""Clicks the link to expand the thread""" """Clicks the link to expand the thread"""
self._find_within(".expand-post").first.click() self._find_within(".forum-thread-expand").first.click()
EmptyPromise( EmptyPromise(
lambda: bool(self.get_response_total_text()), lambda: bool(self.get_response_total_text()),
"Thread expanded" "Thread expanded"
......
...@@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati ...@@ -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 = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201
self.thread_page.visit() 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') @attr('shard_1')
class DiscussionCommentDeletionTest(UniqueCourseTest): class DiscussionCommentDeletionTest(UniqueCourseTest):
......
...@@ -2,6 +2,22 @@ ...@@ -2,6 +2,22 @@
Change Log Change Log
############ ############
*****************
September, 2014
*****************
.. list-table::
:widths: 10 70
:header-rows: 1
* - Date
- Change
* - 09/02/14
- Updated the :ref:`Discussions` and :ref:`Discussions for Students and
Staff` chapters to include information about choosing the type of post
and to reflect changes in the user interface.
************** **************
August, 2014 August, 2014
************** **************
......
...@@ -51,7 +51,7 @@ Create a Discussion Component ...@@ -51,7 +51,7 @@ Create a Discussion Component
course content. The values in the **Category** and **Subcategory** fields course content. The values in the **Category** and **Subcategory** fields
appear in the list of discussion topics on the **Discussion** page. To appear in the list of discussion topics on the **Discussion** page. To
uniquely identify the discussion in your course, each **Category** / uniquely identify the discussion in your course, each **Category** /
**Subcategory** pair that you supply should be unique. **Subcategory** pair that you supply must be unique.
.. image:: ../Images/Discussion_category_subcategory.png .. image:: ../Images/Discussion_category_subcategory.png
:alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded" :alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded"
......
...@@ -161,7 +161,13 @@ D ...@@ -161,7 +161,13 @@ D
**Discussion** **Discussion**
The set of topics defined to promote course-wide or unit-specific dialog. Students use the discussion topics to communicate with each other and the course staff in threaded excahnges. The set of topics defined to promote course-wide or unit-specific
conversation. Students use the discussion topics to communicate with each
other and the course staff in threaded exchanges.
A discussion is also a type of contribution that you can make to a topic to
start an open-ended dialogue. You can also contribute questions to the
discussion topics.
See :ref:`Discussions` for more information. See :ref:`Discussions` for more information.
...@@ -433,6 +439,29 @@ P ...@@ -433,6 +439,29 @@ P
The page in the learning management system that shows students their scores on graded assignments in the course. The page in the learning management system that shows students their scores on graded assignments in the course.
.. _Public Unit:
**Public Unit**
A unit whose **Visibility** option is set to Public so that the unit is visible to students, if the subsection that contains the unit has been released.
See :ref:`Public and Private Units` for more information.
.. _Q:
*****
Q
*****
**Question**
A question is a type of contribution that you can make to a course discussion
topic to surface an issue that the course staff or other students can
resolve.
See :ref:`Discussions` for more information.
.. _R: .. _R:
**** ****
......
...@@ -11,6 +11,10 @@ Change Log ...@@ -11,6 +11,10 @@ Change Log
* - Date * - Date
- Change - Change
* - 09/02/14
- Updated the :ref:`Discussion Forums Data` chapter to include the
``thread_type`` field for CommentThreads and the ``endorsement`` field
for Comments.
* - 08/25/14 * - 08/25/14
- Removed information on course grading. See `Establishing a Grading - Removed information on course grading. See `Establishing a Grading
Policy <http://edx.readthedocs.org/projects/edx-partner-course- Policy <http://edx.readthedocs.org/projects/edx-partner-course-
......
...@@ -217,6 +217,11 @@ on the server. The values in this field are: ...@@ -217,6 +217,11 @@ on the server. The values in this field are:
**Details:** The type of event triggered. Values depend on ``event_source``. **Details:** The type of event triggered. Values depend on ``event_source``.
The :ref:`Student_Event_Types` and :ref:`Instructor_Event_Types` sections in
this chapter provide descriptions of each type of event that is included in
data packages. To locate information about a specific event type, see the
:ref:`event_list`.
=================== ===================
``host`` Field ``host`` Field
=================== ===================
......
...@@ -6,7 +6,7 @@ from django.test.utils import override_settings ...@@ -6,7 +6,7 @@ from django.test.utils import override_settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch, ANY from mock import patch, ANY, Mock
from nose.tools import assert_true, assert_equal # pylint: disable=E0611 from nose.tools import assert_true, assert_equal # pylint: disable=E0611
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -26,9 +26,11 @@ CS_PREFIX = "http://localhost:4567/api/v1" ...@@ -26,9 +26,11 @@ CS_PREFIX = "http://localhost:4567/api/v1"
class MockRequestSetupMixin(object): class MockRequestSetupMixin(object):
def _create_repsonse_mock(self, data):
return Mock(text=json.dumps(data), json=Mock(return_value=data))\
def _set_mock_request_data(self, mock_request, data): def _set_mock_request_data(self, mock_request, data):
mock_request.return_value.text = json.dumps(data) mock_request.return_value = self._create_repsonse_mock(data)
mock_request.return_value.json.return_value = data
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
...@@ -72,6 +74,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -72,6 +74,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
def test_create_thread(self, mock_request): def test_create_thread(self, mock_request):
mock_request.return_value.status_code = 200 mock_request.return_value.status_code = 200
self._set_mock_request_data(mock_request, { self._set_mock_request_data(mock_request, {
"thread_type": "discussion",
"title": "Hello", "title": "Hello",
"body": "this is a post", "body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course", "course_id": "MITx/999/Robot_Super_Course",
...@@ -100,11 +103,13 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -100,11 +103,13 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
"read": False, "read": False,
"comments_count": 0, "comments_count": 0,
}) })
thread = {"body": ["this is a post"], thread = {
"thread_type": "discussion",
"body": ["this is a post"],
"anonymous_to_peers": ["false"], "anonymous_to_peers": ["false"],
"auto_subscribe": ["false"], "auto_subscribe": ["false"],
"anonymous": ["false"], "anonymous": ["false"],
"title": ["Hello"] "title": ["Hello"],
} }
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': self.course_id.to_deprecated_string()}) 'course_id': self.course_id.to_deprecated_string()})
...@@ -114,6 +119,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -114,6 +119,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
'post', 'post',
'{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX), '{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX),
data={ data={
'thread_type': 'discussion',
'body': u'this is a post', 'body': u'this is a post',
'anonymous_to_peers': False, 'user_id': 1, 'anonymous_to_peers': False, 'user_id': 1,
'title': u'Hello', 'title': u'Hello',
...@@ -616,6 +622,53 @@ class ViewPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet ...@@ -616,6 +622,53 @@ class ViewPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def _set_mock_request_thread_and_comment(self, mock_request, thread_data, comment_data):
def handle_request(*args, **kwargs):
url = args[1]
if "/threads/" in url:
return self._create_repsonse_mock(thread_data)
elif "/comments/" in url:
return self._create_repsonse_mock(comment_data)
else:
raise ArgumentError("Bad url to mock request")
mock_request.side_effect = handle_request
def test_endorse_response_as_staff(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.student.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.moderator.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 200)
def test_endorse_response_as_student(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.moderator.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 401)
def test_endorse_response_as_student_question_author(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.student.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 200)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRequestSetupMixin): class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRequestSetupMixin):
...@@ -628,7 +681,7 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq ...@@ -628,7 +681,7 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq
@patch('lms.lib.comment_client.utils.requests.request') @patch('lms.lib.comment_client.utils.requests.request')
def _test_unicode_data(self, text, mock_request): def _test_unicode_data(self, text, mock_request):
self._set_mock_request_data(mock_request, {}) self._set_mock_request_data(mock_request, {})
request = RequestFactory().post("dummy_url", {"body": text, "title": text}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text})
request.user = self.student request.user = self.student
request.view_name = "create_thread" request.view_name = "create_thread"
response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable") response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable")
......
...@@ -103,11 +103,24 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -103,11 +103,24 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
#so by default, a moderator sees all items, and a student sees his cohort #so by default, a moderator sees all items, and a student sees his cohort
query_params = merge_dict(default_query_params, query_params = merge_dict(
strip_none(extract(request.GET, default_query_params,
['page', 'sort_key', strip_none(
'sort_order', 'text', extract(
'commentable_ids', 'flagged']))) request.GET,
[
'page',
'sort_key',
'sort_order',
'text',
'commentable_ids',
'flagged',
'unread',
'unanswered',
]
)
)
)
threads, page, num_pages, corrected_text = cc.Thread.search(query_params) threads, page, num_pages, corrected_text = cc.Thread.search(query_params)
...@@ -150,7 +163,7 @@ def inline_discussion(request, course_id, discussion_id): ...@@ -150,7 +163,7 @@ def inline_discussion(request, course_id, discussion_id):
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'user_info': user_info, 'user_info': user_info,
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
'page': query_params['page'], 'page': query_params['page'],
...@@ -173,7 +186,7 @@ def forum_form_discussion(request, course_id): ...@@ -173,7 +186,7 @@ def forum_form_discussion(request, course_id):
try: try:
unsafethreads, query_params = get_threads(request, course_id) # This might process a search query unsafethreads, query_params = get_threads(request, course_id) # This might process a search query
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
threads = [utils.safe_content(thread, is_staff) for thread in unsafethreads] threads = [utils.safe_content(thread, course_id, is_staff) for thread in unsafethreads]
except cc.utils.CommentClientMaintenanceError: except cc.utils.CommentClientMaintenanceError:
log.warning("Forum is in maintenance mode") log.warning("Forum is in maintenance mode")
return render_to_response('discussion/maintenance.html', {}) return render_to_response('discussion/maintenance.html', {})
...@@ -253,7 +266,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -253,7 +266,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if request.is_ajax(): if request.is_ajax():
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"): with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
content = utils.safe_content(thread.to_dict(), is_staff) content = utils.safe_content(thread.to_dict(), course_id, is_staff)
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"): with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
add_courseware_context([content], course) add_courseware_context([content], course)
return utils.JsonResponse({ return utils.JsonResponse({
...@@ -276,7 +289,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -276,7 +289,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if not "pinned" in thread: if not "pinned" in thread:
thread["pinned"] = False thread["pinned"] = False
threads = [utils.safe_content(thread, is_staff) for thread in threads] threads = [utils.safe_content(thread, course_id, is_staff) for thread in threads]
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"): with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
...@@ -335,7 +348,7 @@ def user_profile(request, course_id, user_id): ...@@ -335,7 +348,7 @@ def user_profile(request, course_id, user_id):
if request.is_ajax(): if request.is_ajax():
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'page': query_params['page'], 'page': query_params['page'],
'num_pages': query_params['num_pages'], 'num_pages': query_params['num_pages'],
'annotated_content_info': _attr_safe_json(annotated_content_info), 'annotated_content_info': _attr_safe_json(annotated_content_info),
...@@ -368,13 +381,30 @@ def followed_threads(request, course_id, user_id): ...@@ -368,13 +381,30 @@ def followed_threads(request, course_id, user_id):
try: try:
profiled_user = cc.User(id=user_id, course_id=course_id) profiled_user = cc.User(id=user_id, course_id=course_id)
query_params = { default_query_params = {
'page': request.GET.get('page', 1), 'page': 1,
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
'sort_key': request.GET.get('sort_key', 'date'), 'sort_key': 'date',
'sort_order': request.GET.get('sort_order', 'desc'), 'sort_order': 'desc',
} }
query_params = merge_dict(
default_query_params,
strip_none(
extract(
request.GET,
[
'page',
'sort_key',
'sort_order',
'flagged',
'unread',
'unanswered',
]
)
)
)
threads, page, num_pages = profiled_user.subscribed_threads(query_params) threads, page, num_pages = profiled_user.subscribed_threads(query_params)
query_params['page'] = page query_params['page'] = page
query_params['num_pages'] = num_pages query_params['num_pages'] = num_pages
...@@ -386,7 +416,7 @@ def followed_threads(request, course_id, user_id): ...@@ -386,7 +416,7 @@ def followed_threads(request, course_id, user_id):
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'page': query_params['page'], 'page': query_params['page'],
'num_pages': query_params['num_pages'], 'num_pages': query_params['num_pages'],
}) })
......
...@@ -5,6 +5,7 @@ Module for checking permissions with the comment_client backend ...@@ -5,6 +5,7 @@ Module for checking permissions with the comment_client backend
import logging import logging
from types import NoneType from types import NoneType
from django.core import cache from django.core import cache
from lms.lib.comment_client import Thread
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
CACHE = cache.get_cache('default') CACHE = cache.get_cache('default')
...@@ -34,31 +35,44 @@ def has_permission(user, permission, course_id=None): ...@@ -34,31 +35,44 @@ def has_permission(user, permission, course_id=None):
return False return False
CONDITIONS = ['is_open', 'is_author'] CONDITIONS = ['is_open', 'is_author', 'is_question_author']
def _check_condition(user, condition, course_id, data): def _check_condition(user, condition, content):
def check_open(user, condition, course_id, data): def check_open(user, content):
try: try:
return data and not data['content']['closed'] return content and not content['closed']
except KeyError: except KeyError:
return False return False
def check_author(user, condition, course_id, data): def check_author(user, content):
try: try:
return data and data['content']['user_id'] == str(user.id) return content and content['user_id'] == str(user.id)
except KeyError:
return False
def check_question_author(user, content):
if not content:
return False
try:
if content["type"] == "thread":
return content["thread_type"] == "question" and content["user_id"] == str(user.id)
else:
# N.B. This will trigger a comments service query
return check_question_author(user, Thread(id=content["thread_id"]).to_dict())
except KeyError: except KeyError:
return False return False
handlers = { handlers = {
'is_open': check_open, 'is_open': check_open,
'is_author': check_author, 'is_author': check_author,
'is_question_author': check_question_author,
} }
return handlers[condition](user, condition, course_id, data) return handlers[condition](user, content)
def _check_conditions_permissions(user, permissions, course_id, **kwargs): def _check_conditions_permissions(user, permissions, course_id, content):
""" """
Accepts a list of permissions and proceed if any of the permission is valid. Accepts a list of permissions and proceed if any of the permission is valid.
Note that ["can_view", "can_edit"] will proceed if the user has either Note that ["can_view", "can_edit"] will proceed if the user has either
...@@ -69,7 +83,7 @@ def _check_conditions_permissions(user, permissions, course_id, **kwargs): ...@@ -69,7 +83,7 @@ def _check_conditions_permissions(user, permissions, course_id, **kwargs):
def test(user, per, operator="or"): def test(user, per, operator="or"):
if isinstance(per, basestring): if isinstance(per, basestring):
if per in CONDITIONS: if per in CONDITIONS:
return _check_condition(user, per, course_id, kwargs) return _check_condition(user, per, content)
return cached_has_permission(user, per, course_id=course_id) return cached_has_permission(user, per, course_id=course_id)
elif isinstance(per, list) and operator in ["and", "or"]: elif isinstance(per, list) and operator in ["and", "or"]:
results = [test(user, x, operator="and") for x in per] results = [test(user, x, operator="and") for x in per]
...@@ -85,7 +99,7 @@ VIEW_PERMISSIONS = { ...@@ -85,7 +99,7 @@ VIEW_PERMISSIONS = {
'create_comment': [["create_comment", "is_open"]], 'create_comment': [["create_comment", "is_open"]],
'delete_thread': ['delete_thread', ['update_thread', 'is_author']], 'delete_thread': ['delete_thread', ['update_thread', 'is_author']],
'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']], 'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment': ['endorse_comment'], 'endorse_comment': ['endorse_comment', 'is_question_author'],
'openclose_thread': ['openclose_thread'], 'openclose_thread': ['openclose_thread'],
'create_sub_comment': [['create_sub_comment', 'is_open']], 'create_sub_comment': [['create_sub_comment', 'is_open']],
'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']], 'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']],
...@@ -115,4 +129,4 @@ def check_permissions_by_view(user, course_id, content, name): ...@@ -115,4 +129,4 @@ def check_permissions_by_view(user, course_id, content, name):
p = VIEW_PERMISSIONS[name] p = VIEW_PERMISSIONS[name]
except KeyError: except KeyError:
logging.warning("Permission for view named %s does not exist in permissions.py" % name) logging.warning("Permission for view named %s does not exist in permissions.py" % name)
return _check_conditions_permissions(user, p, course_id, content=content) return _check_conditions_permissions(user, p, course_id, content)
...@@ -9,7 +9,7 @@ from django.db import connection ...@@ -9,7 +9,7 @@ from django.db import connection
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import simplejson from django.utils import simplejson
from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_common.models import Role, FORUM_ROLE_STUDENT
from django_comment_client.permissions import check_permissions_by_view from django_comment_client.permissions import check_permissions_by_view, cached_has_permission
from edxmako import lookup_template from edxmako import lookup_template
import pystache_custom as pystache import pystache_custom as pystache
...@@ -258,7 +258,6 @@ def get_ability(course_id, content, user): ...@@ -258,7 +258,6 @@ def get_ability(course_id, content, user):
return { return {
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), 'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), 'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, 'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"), 'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
...@@ -293,7 +292,11 @@ def get_annotated_content_infos(course_id, thread, user, user_info): ...@@ -293,7 +292,11 @@ def get_annotated_content_infos(course_id, thread, user, user_info):
def annotate(content): def annotate(content):
infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info) infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
for child in content.get('children', []): for child in (
content.get('children', []) +
content.get('endorsed_responses', []) +
content.get('non_endorsed_responses', [])
):
annotate(child) annotate(child)
annotate(thread) annotate(thread)
return infos return infos
...@@ -361,7 +364,7 @@ def add_courseware_context(content_list, course): ...@@ -361,7 +364,7 @@ def add_courseware_context(content_list, course):
content.update({"courseware_url": url, "courseware_title": title}) content.update({"courseware_url": url, "courseware_title": title})
def safe_content(content, is_staff=False): def safe_content(content, course_id, is_staff=False):
fields = [ fields = [
'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers', 'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at', 'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
...@@ -369,15 +372,42 @@ def safe_content(content, is_staff=False): ...@@ -369,15 +372,42 @@ def safe_content(content, is_staff=False):
'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'unread_comments_count', 'courseware_title', 'courseware_url', 'unread_comments_count',
'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers', 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers',
'stats', 'resp_skip', 'resp_limit', 'resp_total', 'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
'endorsement',
] ]
if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff): if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff):
fields += ['username', 'user_id'] fields += ['username', 'user_id']
if 'children' in content: content = strip_none(extract(content, fields))
safe_children = [safe_content(child) for child in content['children']]
content['children'] = safe_children if content.get("endorsement"):
endorsement = content["endorsement"]
endorser = None
if endorsement["user_id"]:
try:
endorser = User.objects.get(pk=endorsement["user_id"])
except User.DoesNotExist:
log.error("User ID {0} in endorsement for comment {1} but not in our DB.".format(
content.get('user_id'),
content.get('id'))
)
# Only reveal endorser if requester can see author or if endorser is staff
if (
endorser and
("username" in fields or cached_has_permission(endorser, "endorse_comment", course_id))
):
endorsement["username"] = endorser.username
else:
del endorsement["user_id"]
for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
if child_content_key in content:
safe_children = [
safe_content(child, course_id, is_staff) for child in content[child_content_key]
]
content[child_content_key] = safe_children
return strip_none(extract(content, fields)) return content
...@@ -367,12 +367,6 @@ TEMPLATE_CONTEXT_PROCESSORS = ( ...@@ -367,12 +367,6 @@ TEMPLATE_CONTEXT_PROCESSORS = (
# Hack to get required link URLs to password reset templates # Hack to get required link URLs to password reset templates
'edxmako.shortcuts.marketing_link_context_processor', 'edxmako.shortcuts.marketing_link_context_processor',
# Allows the open edX footer to be leveraged in Django Templates.
'edxmako.shortcuts.open_source_footer_context_processor',
# TODO: Used for header and footer feature flags. Remove as part of ECOM-136
'edxmako.shortcuts.header_footer_context_processor',
# Shoppingcart processor (detects if request.user has a cart) # Shoppingcart processor (detects if request.user has a cart)
'shoppingcart.context_processor.user_has_cart_context_processor', 'shoppingcart.context_processor.user_has_cart_context_processor',
) )
......
...@@ -44,12 +44,6 @@ FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" ...@@ -44,12 +44,6 @@ FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com"
WIKI_ENABLED = True WIKI_ENABLED = True
LOGGING = get_logger_config(ENV_ROOT / "log",
logging_env="dev",
local_loglevel="DEBUG",
dev_env=True,
debug=True)
DJFS = { DJFS = {
'type': 'osfs', 'type': 'osfs',
'directory_root': 'lms/static/djpyfs', 'directory_root': 'lms/static/djpyfs',
......
...@@ -11,12 +11,12 @@ class Comment(models.Model): ...@@ -11,12 +11,12 @@ class Comment(models.Model):
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id', 'abuse_flaggers' 'type', 'commentable_id', 'abuse_flaggers', 'endorsement',
] ]
updatable_fields = [ updatable_fields = [
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed' 'user_id', 'endorsed', 'endorsement_user_id',
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields
......
...@@ -35,7 +35,7 @@ class Model(object): ...@@ -35,7 +35,7 @@ class Model(object):
return self.__getattr__(name) return self.__getattr__(name)
def __setattr__(self, name, value): def __setattr__(self, name, value):
if name == 'attributes' or name not in self.accessible_fields: if name == 'attributes' or name not in (self.accessible_fields + self.updatable_fields):
super(Model, self).__setattr__(name, value) super(Model, self).__setattr__(name, value)
else: else:
self.attributes[name] = value self.attributes[name] = value
...@@ -46,7 +46,7 @@ class Model(object): ...@@ -46,7 +46,7 @@ class Model(object):
return self.attributes.get(key) return self.attributes.get(key)
def __setitem__(self, key, value): def __setitem__(self, key, value):
if key not in self.accessible_fields: if key not in (self.accessible_fields + self.updatable_fields):
raise KeyError("Field {0} does not exist".format(key)) raise KeyError("Field {0} does not exist".format(key))
self.attributes.__setitem__(key, value) self.attributes.__setitem__(key, value)
......
...@@ -16,7 +16,8 @@ class Thread(models.Model): ...@@ -16,7 +16,8 @@ class Thread(models.Model):
'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title', 'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned',
'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total' 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
] ]
updatable_fields = [ updatable_fields = [
...@@ -29,7 +30,7 @@ class Thread(models.Model): ...@@ -29,7 +30,7 @@ class Thread(models.Model):
'endorsed', 'read' 'endorsed', 'read'
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields + ['thread_type']
base_url = "{prefix}/threads".format(prefix=settings.PREFIX) base_url = "{prefix}/threads".format(prefix=settings.PREFIX)
default_retrieve_params = {'recursive': False} default_retrieve_params = {'recursive': False}
......
...@@ -37,7 +37,7 @@ def run(): ...@@ -37,7 +37,7 @@ def run():
# Initialize Segment.io analytics module. Flushes first time a message is received and # Initialize Segment.io analytics module. Flushes first time a message is received and
# every 50 messages thereafter, or if 10 seconds have passed since last flush # every 50 messages thereafter, or if 10 seconds have passed since last flush
if settings.FEATURES.get('SEGMENT_IO_LMS') and settings.SEGMENT_IO_LMS_KEY: if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
analytics.init(settings.SEGMENT_IO_LMS_KEY, flush_at=50) analytics.init(settings.SEGMENT_IO_LMS_KEY, flush_at=50)
......
...@@ -49,8 +49,15 @@ ...@@ -49,8 +49,15 @@
// applications // applications
@import "discussion/utilities/variables"; @import "discussion/utilities/variables";
@import "discussion/mixins";
@import 'discussion/discussion'; // Process old file after definitions but before everything else @import 'discussion/discussion'; // Process old file after definitions but before everything else
@import "discussion/elements/actions";
@import "discussion/elements/editor";
@import "discussion/elements/labels";
@import "discussion/elements/navigation"; @import "discussion/elements/navigation";
@import "discussion/views/thread";
@import "discussion/views/new-post";
@import "discussion/views/response";
@import 'discussion/utilities/developer'; @import 'discussion/utilities/developer';
@import 'discussion/utilities/shame'; @import 'discussion/utilities/shame';
......
...@@ -42,6 +42,9 @@ $very-light-text: #fff; ...@@ -42,6 +42,9 @@ $very-light-text: #fff;
// ==================== // ====================
// COLORS - utility
$transparent: rgba(0,0,0,0); // used when color value is needed for UI width/transitions but element is transparent
// COLORS // COLORS
$black: rgb(0,0,0); $black: rgb(0,0,0);
$black-t0: rgba($black, 0.125); $black-t0: rgba($black, 0.125);
......
// discussion - mixins and extends
// ====================
@mixin blue-button {
@include linear-gradient(top, #6dccf1, #38a8e5);
display: block;
border: 1px solid #2d81ad;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
color: $white;
text-shadow: none;
font-size: 13px;
line-height: 35px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
&:hover, &:focus {
@include linear-gradient(top, #4fbbe4, #2090d0);
border-color: #297095;
}
}
@mixin white-button {
@include linear-gradient(top, $white, $gray-l5);
display: block;
border: 1px solid #aaa;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
color: $dark-gray;
text-shadow: none;
font-size: 13px;
line-height: 35px;
&:hover, &:focus {
@include linear-gradient(top, $white, $gray-l6);
}
}
@mixin dark-grey-button {
display: block;
border: 1px solid #222;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
background: -webkit-linear-gradient(top, #777, #555);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
color: $white;
text-shadow: none;
font-size: 13px;
line-height: 35px;
&:hover, &:focus {
background: -webkit-linear-gradient(top, #888, #666);
}
}
@mixin discussion-wmd-input {
@include box-sizing(border-box);
margin-top: 0;
border: 1px solid #aaa;
border-radius: 3px 3px 0 0;
padding: ($baseline/2);
width: 100%;
height: 240px;
background: $white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
font-size: 13px;
font-family: 'Monaco', monospace;
line-height: 1.6;
}
@mixin discussion-wmd-preview-container {
@include box-sizing(border-box);
border: 1px solid #aaa;
border-top: none;
border-radius: 0 0 3px 3px;
width: 100%;
background: #eee;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
}
@mixin discussion-new-post-wmd-preview-container {
@include discussion-wmd-preview-container;
border-color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
@mixin discussion-wmd-preview-label {
padding-top: 3px;
padding-left: 5px;
width: 100%;
color: #bbb;
text-transform: uppercase;
font-size: 11px;
}
@mixin discussion-wmd-preview {
padding: 10px 20px;
width: 100%;
color: #333;
}
@-webkit-keyframes fadeIn {
0% { opacity: 0.0; }
100% { opacity: 1.0; }
}
// extends - content - text overflow by ellipsis
%cont-truncated {
@include box-sizing(border-box);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin forum-post-label($color) {
@extend %t-weight4;
@include font-size(9);
display: inline;
margin-top: ($baseline/4);
border: 1px solid;
border-radius: 3px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
border-color: $color;
color: $color;
.icon {
margin-right: ($baseline/5);
}
&:last-child {
margin-right: 0;
}
&.is-hidden {
display: none;
}
}
@mixin forum-user-label($color) {
@include font-size(9);
@extend %t-weight5;
vertical-align: middle;
margin-left: ($baseline/4);
border-radius: 2px;
padding: 0 ($baseline/5);
background: $color;
font-style: normal;
text-transform: uppercase;
color: white;
}
.discussion.container, .discussion-module {
// discussion - elements - actions
// ====================
// UI: general action list
.post-actions-list,
.response-actions-list,
.comment-actions-list {
@extend %ui-no-list;
text-align: right;
.actions-item {
@include box-sizing(border-box);
display: block;
margin: ($baseline/4) 0;
&.is-hidden {
display: none;
}
}
.more-wrapper {
position: relative;
}
}
// ====================
// UI: general actions dropdown layout
.actions-dropdown {
@extend %ui-no-list;
@extend %ui-depth1;
display: none;
position: absolute;
top: 100%;
right: 0;
pointer-events: none;
min-width: ($baseline*6.5);
&.is-expanded {
display: block;
pointer-events: auto;
}
.actions-dropdown-list {
@include box-sizing(border-box);
box-shadow: 0 1px 1px $shadow-l1;
position: relative;
width: 100%;
border-radius: 3px;
margin: 5px 0 0 0;
border: 1px solid $gray-l3;
padding: ($baseline/2) ($baseline*0.75);
background: $white;
// ui triangle/nub
&:after,
&:before {
bottom: 100%;
right: 3px;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
&:after {
border-color: $transparent;
border-bottom-color: $white;
border-width: 6px;
margin-right: 1px;
}
&:before {
border-color: $transparent;
border-bottom-color: $gray-l3;
border-width: 7px;
}
}
.actions-item {
display: block;
margin: 0;
&.is-hidden {
display: none;
}
}
}
// ====================
// UI: general action
.action-button {
@include transition(border .5s linear 0s);
@include box-sizing(border-box);
display: inline-block;
border: 1px solid transparent;
border-radius: 5px;
color: $gray-l1;
.action-icon {
@extend %t-icon7;
display: inline-block;
height: $baseline;
width: $baseline;
border: 1px solid $gray-l3;
border-radius: 3px;
text-align: center;
color: $gray-l1;
.icon {
vertical-align: middle;
}
}
.action-label {
@extend %t-copy-sub2;
display: inline-block;
vertical-align: middle;
padding: 0 8px;
color: $gray-l1;
opacity: 0;
}
&:hover, &:focus {
.action-label {
opacity: 1;
}
.action-icon {
border-radius: 0 3px 3px 0;
}
}
// specific button styles
&.action-follow {
.action-label {
color: $blue-d1;
}
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $forum-color-following;
border: 1px solid $blue-d1;
color: $white;
}
}
&:hover, &:focus {
border-color: $forum-color-following;
}
}
&.action-vote {
.action-label {
opacity: 1;
}
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $green-d1;
border: 1px solid $green-d2;
color: $white;
}
}
&:hover, &:focus {
border-color: $green-d2;
.action-label {
color: $green-d2;
}
}
}
&.action-endorse {
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $blue-d1;
border: 1px solid $blue-d2;
color: $white;
}
}
&:hover, &:focus {
border-color: $blue-d2;
.action-label {
color: $blue-d2;
}
}
}
&.action-answer {
&.is-checked, &:hover, &:focus {
.action-icon {
border: 1px solid $green-d1;
background-color: $green-d1;
color: $white;
}
}
&:hover, &:focus {
border-color: $green-d1;
.action-label {
color: $green-d2;
}
}
}
// more drop-down menu
&.action-more {
position: relative;
&:hover, &:focus {
border-color: $gray;
.action-icon {
border: 1px solid $gray;
background-color: $gray;
color: $white;
}
.action-label {
opacity: 1;
color: $black;
}
}
}
}
// ====================
.actions-dropdown {
// UI: secondary action
.action-list-item {
@extend %t-copy-sub2;
display: block;
padding: ($baseline/10) 0;
white-space: nowrap;
text-align: right;
color: $gray-l1;
&:hover, &:focus {
color: $link-color;
}
.action-icon {
display: inline-block;
width: ($baseline/2);
margin-left: ($baseline/4);
color: inherit;
}
.action-label {
display: inline-block;
color: inherit;
}
// CASE: checked
&.is-checked {
// CASE: pin action
&.action-pin {
color: $pink;
}
// CASE: report action
&.action-report {
color: $pink;
}
// CASE: hover for any action
&:hover, &:focus {
color: $link-color;
}
}
}
}
.action-button, .action-list-item {
.action-label {
.label-checked {
display: none;
}
}
&.is-checked {
.label-unchecked {
display: none;
}
.label-checked {
display: inline;
}
}
}
}
// discussion - elements - editor
// ====================
// UI: general editor styling
// TO-DO: isolate out all editing styling from _discussion.scss and clean up cases defined below once general syling exists
// =========================
// CASE: new post
.forum-new-post-form {
.wmd-input {
@include discussion-wmd-input;
@include box-sizing(border-box);
position: relative;
z-index: 1;
width: 100%;
height: 150px;
background: $white;
}
.wmd-preview-container {
@include discussion-new-post-wmd-preview-container;
}
.wmd-preview-label {
@include discussion-wmd-preview-label;
}
.wmd-preview {
@include discussion-wmd-preview;
}
.wmd-button {
background: none;
}
}
// =========================
// CASE: inline styling
// TO-DO: additional styling cleanup here necessary, for now this case was ported over from _discussion.scss
.discussion-module {
.wmd-panel {
width: 100%;
min-width: 500px;
}
.wmd-button-bar {
width: 100%;
}
.wmd-input {
width: 100%;
height: 150px;
border-radius: 3px 3px 0 0;
font-style: normal;
font-size: 0.8em;
font-family: Monaco, 'Lucida Console', monospace;
line-height: 1.6em;
&::-webkit-input-placeholder {
color: #888;
}
}
.wmd-button-row {
@include transition(all .2s ease-out 0s);
position: relative;
overflow: hidden;
margin: ($baseline/2) ($baseline/4) ($baseline/4) ($baseline/4);
padding: 0;
height: 30px;
}
.wmd-spacer {
position: absolute;
display: inline-block;
margin-left: 14px;
width: 1px;
height: 20px;
background-color: Silver;
list-style: none;
}
.wmd-button {
position: absolute;
display: inline-block;
padding-right: 3px;
padding-left: 2px;
width: 20px;
height: 20px;
background: none;
list-style: none;
cursor: pointer;
}
.wmd-button > span {
display: inline-block;
width: 20px;
height: 20px;
background-image: url('/static/images/wmd-buttons-transparent.png');
background-position: 0px 0px;
background-repeat: no-repeat;
}
.wmd-spacer1 {
left: 50px;
}
.wmd-spacer2 {
left: 175px;
}
.wmd-spacer3 {
left: 300px;
}
.wmd-prompt-background {
background-color: Black;
}
.wmd-prompt-dialog {
@extend .modal;
background: $white;
}
.wmd-prompt-dialog {
padding: $baseline;
> div {
font-size: 0.8em;
font-family: arial, helvetica, sans-serif;
}
b {
font-size: 16px;
}
> form > input[type="text"] {
border-radius: 3px;
color: #333;
}
> form > input[type="button"] {
border: 1px solid #888;
font-family: $sans-serif;
font-size: 14px;
}
> form > input[type="file"] {
margin-bottom: 18px;
}
}
.wmd-button-row {
// this is being hidden now because the inline styles to position the icons are not being written
position: relative;
height: 25px;
}
.wmd-button {
span {
background-image: url("/static/images/wmd-buttons.png");
display: inline-block;
}
}
}
// discussion - elements - labels
// ====================
body.discussion, .discussion-module {
.post-label-pinned {
@include forum-post-label($forum-color-pinned);
}
.post-label-following {
@include forum-post-label($forum-color-following);
}
.post-label-reported {
@include forum-post-label($forum-color-reported);
}
.post-label-closed {
@include forum-post-label($forum-color-closed);
}
.post-label-by-staff {
@include forum-post-label($forum-color-staff);
}
.post-label-by-community-ta {
@include forum-post-label($forum-color-community-ta);
}
.user-label-staff {
@include forum-user-label($forum-color-staff);
}
.user-label-community-ta {
@include forum-user-label($forum-color-community-ta);
}
}
\ No newline at end of file
// discussion - elements - navigation
// ====================
.forum-nav { .forum-nav {
@include box-sizing(border-box); @include box-sizing(border-box);
float: left; float: left;
...@@ -124,21 +127,35 @@ ...@@ -124,21 +127,35 @@
background-color: $gray-l5; background-color: $gray-l5;
padding: ($baseline/4) ($baseline/2); padding: ($baseline/4) ($baseline/2);
color: $black; color: $black;
text-align: right;
}
.forum-nav-filter-main {
@include box-sizing(border-box);
display: inline-block;
width: 50%;
text-align: left;
}
.forum-nav-filter-cohort, .forum-nav-sort {
@include box-sizing(border-box);
display: inline-block;
width: 50%;
text-align: right;
} }
%forum-nav-select { %forum-nav-select {
border: none; border: none;
max-width: 100%; max-width: 100%;
background-color: transparent; background-color: transparent;
font: inherit;
} }
.forum-nav-filter-cohort-control { .forum-nav-filter-main-control {
@extend %forum-nav-select; @extend %forum-nav-select;
} }
.forum-nav-sort { .forum-nav-filter-cohort-control {
float: right; @extend %forum-nav-select;
} }
.forum-nav-sort-control { .forum-nav-sort-control {
...@@ -176,65 +193,41 @@ ...@@ -176,65 +193,41 @@
vertical-align: middle; vertical-align: middle;
} }
.forum-nav-thread-wrapper-1 { .forum-nav-thread-wrapper-0 {
@extend %forum-nav-thread-wrapper;
width: 70%;
}
.forum-nav-thread-wrapper-2 {
@extend %forum-nav-thread-wrapper; @extend %forum-nav-thread-wrapper;
width: 30%; width: 7%;
text-align: right;
}
.forum-nav-thread-title {
@extend %t-title7;
display: block;
}
%forum-nav-thread-label { .icon {
@extend %t-weight4; @include font-size(14);
@include font-size(9);
display: inline;
margin-top: ($baseline/4);
border: 1px solid;
border-radius: 3px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
&:last-child {
margin-right: 0;
} }
.icon { .icon-comments {
margin-right: ($baseline/5); color: $gray-l2;
} }
} .icon-ok {
color: $forum-color-marked-answer;
}
.forum-nav-thread-label-pinned { .icon-question {
@extend %forum-nav-thread-label; color: $pink;
border-color: $forum-color-pinned; }
color: $forum-color-pinned;
} }
.forum-nav-thread-label-following { .forum-nav-thread-wrapper-1 {
@extend %forum-nav-thread-label; @extend %forum-nav-thread-wrapper;
border-color: $forum-color-following; width: 80%;
color: $forum-color-following;
} }
.forum-nav-thread-label-staff { .forum-nav-thread-wrapper-2 {
@extend %forum-nav-thread-label; @extend %forum-nav-thread-wrapper;
border-color: $forum-color-staff; width: 13%;
color: $forum-color-staff; text-align: right;
} }
.forum-nav-thread-label-community-ta { .forum-nav-thread-title {
@extend %forum-nav-thread-label; @extend %t-title7;
border-color: $forum-color-community-ta; display: block;
color: $forum-color-community-ta;
} }
%forum-nav-thread-wrapper-2-content { %forum-nav-thread-wrapper-2-content {
...@@ -249,11 +242,6 @@ ...@@ -249,11 +242,6 @@
} }
} }
.forum-nav-thread-endorsed {
@extend %forum-nav-thread-wrapper-2-content;
color: $green-d1;
}
.forum-nav-thread-votes-count { .forum-nav-thread-votes-count {
@extend %forum-nav-thread-wrapper-2-content; @extend %forum-nav-thread-wrapper-2-content;
} }
......
...@@ -66,9 +66,16 @@ ...@@ -66,9 +66,16 @@
// navigation - sort and filter bar // navigation - sort and filter bar
// -------------------------------- // --------------------------------
// Override global span rules // Override global label rules
.forum-nav-sort-label { .forum-nav-filter-main, .forum-nav-filter-cohort, .forum-nav-sort {
color: inherit; font: inherit;
line-height: 1em;
margin-bottom: 0;
}
// Override global select rules
.forum-nav-filter-main-control, .forum-nav-filter-cohort-control, .forum-nav-sort-control {
font: inherit;
} }
// -------------------------------- // --------------------------------
...@@ -95,3 +102,55 @@ li[class*=forum-nav-thread-label-] { ...@@ -95,3 +102,55 @@ li[class*=forum-nav-thread-label-] {
display: none !important; display: none !important;
} }
} }
// -------------
// new post form
// -------------
.forum-new-post-form {
// Override global label rules
.post-type {
text-shadow: none;
}
.post-type, .topic-filter-label {
margin-bottom: 0;
}
// Override global ul rules
.topic-menu {
padding-left: 0;
}
.topic-menu, .topic-submenu {
margin-top: 0;
margin-bottom: 0;
}
// Override global span rules
.post-topic-button .drop-arrow {
line-height: 36px;
}
.topic-title {
line-height: 14px;
}
}
// -------
// Actions
// -------
.discussion.container, .discussion-module {
// Override courseware
.post-actions-list, .response-actions-list, .comment-actions-list {
@extend %t-copy-sub2;
padding-left: 0 !important;
}
// Override global span
.action-label span, .action-icon span {
color: inherit;
}
}
$forum-color-active-thread: tint($blue, 85%); $forum-color-active-thread: tint($blue, 85%);
$forum-color-pinned: $pink; $forum-color-pinned: $pink;
$forum-color-reported: $pink;
$forum-color-closed: $black;
$forum-color-following: $blue; $forum-color-following: $blue;
$forum-color-staff: $blue; $forum-color-staff: $blue;
$forum-color-community-ta: $green-d1; $forum-color-community-ta: $green-d1;
$forum-color-marked-answer: $green-d1;
// discussion - views - new post
// ====================
// UI: form structure
.forum-new-post-form {
@include clearfix;
box-sizing: border-box;
margin: 0;
border-radius: 3px;
padding: ($baseline*2);
min-width: 760px;
max-width: 1180px;
background: $gray-l5;
.post-field {
margin-bottom: $baseline;
.field-label {
display: inline-block;
width: 50%;
vertical-align: top;
line-height: 40px;
.field-input {
display: inline-block;
width: 100%;
vertical-align: top;
}
.field-label-text {
display: inline-block;
width: 25%;
vertical-align: top;
text-transform: uppercase;
font-size: 12px;
line-height: 40px;
}
.field-label-text + .field-input {
width: 75%;
}
}
// UI: support text for input fields
.field-help {
@include box-sizing(border-box);
display: inline-block;
padding-left: $baseline;
width: 50%;
font-size: 12px;
}
}
.post-options {
margin-bottom: ($baseline/2);
}
}
// CASE: inline styling
.discussion-module .forum-new-post-form {
background: $white;
}
// ====================
// UI: inputs
.forum-new-post-form {
.post-topic-button {
@include white-button;
@extend %cont-truncated;
z-index: 1000;
padding: 0 $baseline 0 ($baseline*.75);
height: 40px;
font-size: 14px;
line-height: 36px;
.drop-arrow {
float: right;
color: #999;
}
}
.post-type-input {
@extend %text-sr;
}
.post-type-label {
@extend %cont-truncated;
@include box-sizing(border-box);
@include white-button;
@include font-size(14);
display: inline-block;
padding: 0 ($baseline/2);
width: 48%;
height: 40px;
text-align: center;
color: $gray-d3;
font-weight: 600;
line-height: 36px;
.icon {
margin-right: 5px;
}
}
.post-type-input:checked + .post-type-label {
background-color: $forum-color-active-thread;
background-image: none;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset;
}
.post-type-input:focus + .post-type-label {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset, 0 0 2px 2px $blue;
}
input[type=text].field-input {
@include box-sizing(border-box);
border: 1px solid $gray-l2;
border-radius: 3px;
padding: 0 $baseline/2;
height: 40px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
color: #333;
font-weight: 700;
font-size: 16px;
font-family: 'Open Sans', sans-serif;
}
.post-option {
@include box-sizing(border-box);
display: inline-block;
margin-right: $baseline;
border: 1px solid transparent;
border-radius: 3px;
padding: ($baseline/2);
&:hover {
border-color: $gray-l3;
}
&.is-enabled {
border-color: $blue;
color: $blue;
}
.post-option-input {
margin-right: ($baseline/2);
}
.icon {
margin-right: 0.5em;
}
}
}
// ====================
// UI: actions
.forum-new-post-form {
.submit {
@include blue-button;
display: inline-block;
margin-right: ($baseline/2);
}
.cancel {
@include white-button;
display: inline-block;
}
}
// ====================
// UI: errors - new post creation
.forum-new-post-form {
.post-errors {
margin-bottom: $baseline;
border-radius: 3px;
padding: 0;
background: $error-red;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset, 0 1px 0 rgba(255, 255, 255, .2);
color: $white;
list-style: none;
.post-error {
padding: ($baseline/2) $baseline 12px 45px;
border-bottom: 1px solid $red;
background: url(../images/white-error-icon.png) no-repeat 15px 14px;
&:last-child {
border-bottom: none;
}
}
}
}
// ====================
// UI: topic menu
// TO-DO: refactor to use _navigation.scss as general topic selector
.forum-new-post-form .post-topic {
position: relative;
.topic-menu-wrapper {
@include box-sizing(border-box);
position: absolute;
top: 40px;
left: 0;
z-index: 9999;
border: 1px solid $gray-l3;
width: 100%;
background: $white;
box-shadow: 0 2px 1px $shadow;
}
.topic-filter-label {
border-bottom: 1px solid $gray-l2;
padding: ($baseline/4);
}
.topic-filter-input {
@include box-sizing(border-box);
border: 1px solid $gray-l3;
padding: 0 15px;
width: 100%;
height: 30px;
color: #333;
font-size: 11px;
line-height: 16px;
}
.topic-menu {
overflow-y: scroll;
max-height: 400px;
list-style: none;
}
.topic-submenu {
padding-left: $baseline;
list-style: none;
}
.topic-title {
display: block;
border-bottom: 1px solid $gray-l3;
padding: ($baseline/2);
font-size: 14px;
}
a.topic-title {
@include transition(none);
&:hover, &:focus {
background-color: $gray-l4;
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment