Commit b8049c01 by Greg Price

Merge pull request #4957 from edx/forums/new-post-type

Add post types to forums

This requires cs_comments_service@66482b54
parents b89dccdc c9bf9ada
......@@ -41,11 +41,11 @@ describe 'All Content', ->
it 'can update info', ->
@content.updateInfo {
ability: 'can_endorse',
ability: {'can_edit': true},
voted: true,
subscribed: true
}
expect(@content.get 'ability').toEqual 'can_endorse'
expect(@content.get 'ability').toEqual {'can_edit': true}
expect(@content.get 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true
......@@ -77,3 +77,39 @@ describe 'All Content', ->
myComments = new Comments
myComments.add @comment1
expect(myComments.find('123')).toBe @comment1
it 'can be endorsed', ->
DiscussionUtil.loadRoles(
{"Moderator": [111], "Administrator": [222], "Community TA": [333]}
)
@discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99})
@discussionResponse = new Comment({id: 1, thread: @discussionThread})
@questionThread = new Thread({id: 1, thread_type: "question", user_id: 99})
@questionResponse = new Comment({id: 1, thread: @questionThread})
# mod
window.user = new DiscussionUser({id: 111})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# admin
window.user = new DiscussionUser({id: 222})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# TA
window.user = new DiscussionUser({id: 333})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# thread author
window.user = new DiscussionUser({id: 99})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# anyone else
window.user = new DiscussionUser({id: 999})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(false)
......@@ -3,4 +3,606 @@ class @DiscussionSpecHelper
@setUpGlobals = ->
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
window.$$course_id = "edX/999/test"
window.user = new DiscussionUser({id: "567", upvoted_ids: []})
window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []})
DiscussionUtil.setUser(window.user)
@makeModerator = () ->
DiscussionUtil.roleIds["Moderator"].push(parseInt(window.user.id))
@setUnderscoreFixtures = ->
appendSetFixtures("""
<div id="fixture-element"></div>
<!--
NOTE the html markup here comes from rendering lms/templates/discussion/_underscore_templates.html through a
browser and pasting the output. When that file changes, this one should be regenerated alongside it.
-->
<script aria-hidden="true" type="text/template" id="thread-template">
<article class="discussion-article" data-id="<%- id %>">
<div class="thread-wrapper">
<div class="forum-thread-main-wrapper">
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<ol class="responses js-marked-answer-list"></ol>
</div>
</div>
<div class="post-extended-content">
<div class="response-count"/>
<div class="add-response">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">Add A Response</span>
</button>
</div>
<ol class="responses js-response-list"/>
<div class="response-pagination"/>
<div class="post-status-closed bottom-post-status" style="display: none">
This thread is closed.
</div>
<form class="discussion-reply-new" data-id="<%- id %>">
<h4>Post a response:</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="<%- id %>"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">Submit</a>
</div>
</form>
</div>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="forum-thread-expand"><span class="icon icon-plus"/> Expand discussion</a>
<a href="javascript:void(0)" class="forum-thread-collapse"><span class="icon icon-minus"/> Collapse discussion</a>
</div>
</article>
</script>
<script aria-hidden="true" type="text/template" id="thread-show-template">
<div class="discussion-post">
<header>
<% if (obj.group_id) { %>
<div class="group-visibility-label"><%- obj.group_string%></div>
<% } %>
<div class="post-header-content">
<h1><%- title %></h1>
<p class="posted-details">
<%- thread_type %> posted <span class='timeago' title='<%- created_at %>'><%- created_at %></span> by <%= author_display %>
</p>
<div class="post-labels">
<span class="post-label-pinned"><i class="icon icon-pushpin"></i>Pinned</span>
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
<span class="post-label-closed"><i class="icon icon-lock"></i>Closed</span>
</div>
</div>
<div class="post-header-actions post-extended-content">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'post',
primaryActions: ['vote', 'follow'],
secondaryActions: ['pin', 'edit', 'delete', 'report', 'close']
}
)
%>
</div>
</header>
<div class="post-body"><%- body %></div>
<% if (mode == "tab" && obj.courseware_url) { %>
<div class="post-context"><%
var courseware_link = interpolate('<a href="%s">%s</a>', [courseware_url, _.escape(courseware_title)]);
print(interpolate('(this post is about %(courseware_title_linked)s)', {'courseware_title_linked': courseware_link}, true));
%></div>
<% } %>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-edit-template">
<div class="discussion-post edit-post-form">
<h1>Editing post</h1>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<label class="sr" for="edit-post-title">Edit post title</label>
<input type="text" id="edit-post-title" class="edit-post-title" name="title" value="<%-title %>" placeholder="Title">
</div>
<div class="form-row">
<div class="edit-post-body" name="body"><%- body %></div>
</div>
<input type="submit" id="edit-post-submit" class="post-update" value="Update post">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-template">
<div class="discussion-response"></div>
<a href="#" class="action-show-comments">
<%- interpolate('Show Comments (%(num_comments)s)', {num_comments: comments.length}, true) %>
<i class="icon icon-caret-down"></i>
</a>
<ol class="comments">
<li class="new-comment">
<form class="comment-form" data-id="<%- wmdId %>">
<ul class="discussion-errors"></ul>
<label class="sr" for="add-new-comment">Add a comment</label>
<div class="comment-body" id="add-new-comment" data-id="<%- wmdId %>"
data-placeholder="Add a comment..."></div>
<div class="comment-post-control">
<a class="discussion-submit-comment control-button" href="#">Submit</a>
</div>
</form>
</li>
</ol>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-show-template">
<header>
<div class="response-header-content">
<%= author_display %>
<p class="posted-details">
<span class="timeago" title="<%= created_at %>"><%= created_at %></span>
<% if (obj.endorsement) { %> - <%=
interpolate(
thread.get("thread_type") == "question" ?
(endorsement.username ? "marked as answer %(time_ago)s by %(user)s" : "marked as answer %(time_ago)s") :
(endorsement.username ? "endorsed %(time_ago)s by %(user)s" : "endorsed %(time_ago)s"),
{
'time_ago': '<span class="timeago" title="' + endorsement.time + '">' + endorsement.time + '</span>',
'user': endorser_display
},
true
)%><% } %>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
</div>
</div>
<div class="response-header-actions">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'response',
primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
</div>
</header>
<div class="response-body"><%- body %></div>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-edit-template">
<div class="edit-post-form">
<h1>Editing response</h1>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<div class="edit-post-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-response-submit"class="post-update" value="Update response">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="response-comment-show-template">
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'comment',
primaryActions: [],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
<p class="posted-details">
<%=
interpolate(
'posted %(time_ago)s by %(author)s',
{'time_ago': '<span class="timeago" title="' + created_at + '">' + created_at + '</span>', 'author': author_display},
true
)%>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>Reported</span>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="response-comment-edit-template">
<div class="edit-post-form" id="comment_<%- id %>">
<h1>Editing comment</h1>
<ul class="edit-comment-form-errors"></ul>
<div class="form-row">
<div class="edit-comment-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-comment-submit" class="post-update" value="Update comment">
<a href="#" class="post-cancel">Cancel</a>
</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-list-item-template">
<li data-id="<%- id %>" class="forum-nav-thread<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-0">
<%
var icon_class, sr_text;
if (thread_type == "discussion") {
icon_class = "icon-comments";
sr_text = "discussion";
} else if (endorsed) {
icon_class = "icon-ok";
sr_text = "answered question";
} else {
icon_class = "icon-question";
sr_text = "unanswered question";
}
%>
<span class="sr"><%= sr_text %></span>
<i class="icon <%= icon_class %>"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title"><%- title %></span>
<%
var labels = "";
if (pinned) {
labels += '<li class="post-label-pinned"><i class="icon icon-pushpin"></i>Pinned</li> ';
}
if (typeof(subscribed) != "undefined" && subscribed) {
labels += '<li class="post-label-following"><i class="icon icon-star"></i>Following</li> ';
}
if (staff_authored) {
labels += '<li class="post-label-by-staff"><i class="icon icon-user"></i>By: Staff</li> ';
}
if (community_ta_authored) {
labels += '<li class="post-label-by-community-ta"><i class="icon icon-user"></i>By: Community TA</li> ';
}
if (labels != "") {
print('<ul class="forum-nav-thread-labels">' + labels + '</ul>');
}
%>
</div><div class="forum-nav-thread-wrapper-2">
<span class="forum-nav-thread-votes-count">+<%=
interpolate(
'%(votes_up_count)s%(span_sr_open)s votes %(span_close)s',
{'span_sr_open': '<span class="sr">', 'span_close': '</span>', 'votes_up_count': votes['up_count']},
true
)
%></span>
<span class="forum-nav-thread-comments-count <% if (unread_comments_count > 0) { %>is-unread<% } %>">
<%
var fmt;
// Counts in data do not include the post itself, but the UI should
var data = {
'span_sr_open': '<span class="sr">',
'span_close': '</span>',
'unread_comments_count': unread_comments_count + (read ? 0 : 1),
'comments_count': comments_count + 1
};
if (unread_comments_count > 0) {
fmt = '%(comments_count)s %(span_sr_open)scomments (%(unread_comments_count)s unread comments)%(span_close)s';
} else {
fmt = '%(comments_count)s %(span_sr_open)scomments %(span_close)s';
}
print(interpolate(fmt, data, true));
%>
</span>
</div>
</a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="discussion-home">
<div class="discussion-article blank-slate">
<section class="home-header">
<span class="label">DISCUSSION HOME:</span>
<h1 class="home-title">Cohort Course</h1>
</section>
</div>
</script>
<script aria-hidden="true" type="text/template" id="search-alert-template">
<div class="search-alert" id="search-alert-<%- cid %>">
<div class="search-alert-content">
<p class="message"><%= message %></p>
</div>
<div class="search-alert-controls">
<a href="#" class="dismiss control control-dismiss"><i class="icon icon-remove"></i></a>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-template">
<form class="forum-new-post-form">
<ul class="post-errors" style="display: none"></ul>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Post type:
</span><fieldset class="field-input">
<input type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-question" value="question" checked>
<label for="<%= form_id %>-post-type-question" class="post-type-label">
<i class="icon icon-question"></i>
Question
</label>
<input type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-discussion" value="discussion">
<label for="<%= form_id %>-post-type-discussion" class="post-type-label">
<i class="icon icon-comments"></i>
Discussion
</label>
</fieldset>
</div><span class="field-help">
Questions raise issues that need answers. Discussions share ideas and start conversations.
</span>
</div>
<% if (mode=="tab") { %>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Topic Area:
</span><div class="field-input post-topic">
<a href="#" class="post-topic-button">
<span class="sr">Discussion topics; current selection is: </span>
<span class="js-selected-topic"></span>
<span class="drop-arrow" aria-hidden="true">▾</span>
</a>
<div class="topic-menu-wrapper">
<label class="topic-filter-label">
<span class="sr">Filter topics</span>
<input type="text" class="topic-filter-input" placeholder="Filter topics">
</label>
<ul class="topic-menu" role="menu"><%= topics_html %></ul>
</div>
</div>
</div><span class="field-help">
Add your post to a relevant topic to help others find it.
</span>
</div>
<% } %>
<% if (cohort_options) { %>
<div class="post-field">
<label class="field-label">
<span class="field-label-text">
Visible To:
</span><select class="field-input js-group-select" name="group_id">
<option value="">All Groups</option>
<% _.each(cohort_options, function(opt) { %>
<option value="<%= opt.value %>" <% if (opt.selected) { %>selected<% } %>><%- opt.text %></option>
<% }); %>
</select>
</label><div class="field-help">
Instructors can set whether a post in a cohorted topic is visible to all cohorts or only to a specific cohort.
</div>
</div>
<% } %>
<div class="post-field">
<label class="field-label">
<span class="sr">Title:</span>
<input type="text" class="field-input js-post-title" name="title" placeholder="Title">
</label><span class="field-help">
Add a clear and descriptive title to encourage participation.
</span>
</div>
<div class="post-field js-post-body editor" name="body" data-placeholder="Enter your question or comment…"></div>
<div class="post-options">
<label class="post-option is-enabled">
<input type="checkbox" name="follow" class="post-option-input js-follow" checked>
<i class="icon icon-star"></i>follow this post
</label>
<% if (allow_anonymous) { %>
<label class="post-option">
<input type="checkbox" name="anonymous" class="post-option-input js-anon">
post anonymously
</label>
<% } %>
<% if (allow_anonymous_to_peers) { %>
<label class="post-option">
<input type="checkbox" name="anonymous_to_peers" class="post-option-input js-anon-peers">
post anonymously to classmates
</label>
<% } %>
</div>
<div>
<input type="submit" class="submit" value="Add Post">
<a href="#" class="cancel">Cancel</a>
</div>
</form>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem" class="topic-menu-item">
<a href="#" class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_cohorted %>"><%- text %></a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem" class="topic-menu-item">
<span class="topic-title"><%- text %></span>
<ul role="menu" class="topic-submenu"><%= entries %></ul>
</li>
</script>
<script type="text/template" id="forum-action-endorse">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-endorse" role="checkbox" aria-checked="false">
<span class="sr">Endorse</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Endorse</span>
<span class="label-checked">Unendorse</span>
</span>
<span class="action-icon"><i class="icon icon-ok"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-answer">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-answer" role="checkbox" aria-checked="false">
<span class="sr">Mark as Answer</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Mark as Answer</span>
<span class="label-checked">Unmark as Answer</span>
</span>
<span class="action-icon"><i class="icon icon-ok"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-follow">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-follow" role="checkbox" aria-checked="false">
<span class="sr">Follow</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Follow</span>
<span class="label-checked">Unfollow</span>
</span>
<span class="action-icon"><i class="icon icon-star"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-vote">
<li class="actions-item">
<a href="#" class="action-button action-vote" role="checkbox" aria-checked="false">
<span class="sr">Vote</span>
<span class="sr js-sr-vote-count"></span>
<span class="action-label" aria-hidden="true">
<span class="js-visual-vote-count"></span>
</span>
<span class="action-icon" aria-hidden="true">
<i class="icon icon-plus"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-report">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-report" role="checkbox" aria-checked="false">
<span class="sr">Report abuse</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Report</span>
<span class="label-checked">Unreport</span>
</span>
<span class="action-icon">
<i class="icon icon-flag"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-pin">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-pin" role="checkbox" aria-checked="false">
<span class="sr">Pin</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Pin</span>
<span class="label-checked">Unpin</span>
</span>
<span class="action-icon">
<i class="icon icon-pushpin"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-close">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-close" role="checkbox" aria-checked="false">
<span class="sr">Close</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">Close</span>
<span class="label-checked">Open</span>
</span>
<span class="action-icon">
<i class="icon icon-lock"></i>
</span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-edit">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
<span class="action-label">Edit</span>
<span class="action-icon"><i class="icon icon-pencil"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-action-delete">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-delete" role="button">
<span class="action-label">Delete</span>
<span class="action-icon"><i class="icon icon-remove"></i></span>
</a>
</li>
</script>
<script type="text/template" id="forum-actions">
<ul class="<%= contentType %>-actions-list">
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<li class="actions-item is-visible">
<div class="more-wrapper">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>">
<span class="action-label">More</span>
<span class="action-icon"><i class="icon icon-ellipsis-horizontal"></i></span>
</a>
<div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false">
<ul class="actions-dropdown-list">
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
</ul>
</div>
</div>
</li>
</ul>
</script>
<script aria-hidden="true" type="text/template" id="post-user-display-template">
<% if (username) { %>
<a href="<%- user_url %>" class="username"><%- username %></a>
<% if (is_community_ta) { %>
<span class="user-label-community-ta">Community TA</span>
<% } else if (is_staff) { %>
<span class="user-label-staff">Staff</span>
<% } %>
<% } else { %>
anonymous
<% } %>
</script>
""")
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", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<header>
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class='votes-count-number'>0</span> <span class="sr">votes (click to vote)</span></a>
<h1>Post Title</h1>
<p class="posted-details">
<a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a>
<span title="2013-05-08T17:34:07Z" class="timeago">less than a minute ago</span>
</p>
</header>
<div class="post-body"><p>Post body.</p></div>
<div data-tooltip="Report Misuse" data-role="thread-flag" class="discussion-flag-abuse notflagged">
<i class="icon"></i><span class="flag-label">Report Misuse</span></div>
<div data-tooltip="pin this thread" class="admin-pin discussion-pin notpinned">
<i class="icon"></i><span class="pin-label">Pin Thread</span></div>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: '01234567',
......@@ -35,7 +16,8 @@ describe "DiscussionContentView", ->
}
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post'))
@view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
......@@ -59,15 +41,3 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual []
it 'renders the vote button properly', ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it 'votes correctly', ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, false)
it 'unvotes correctly', ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, false)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
......@@ -6,7 +6,23 @@ describe "DiscussionThreadListView", ->
<script type="text/template" id="thread-list-item-template">
<li data-id="<%- id %>" class="forum-nav-thread<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-1">
<div class="forum-nav-thread-wrapper-0">
<%
var icon_class, sr_text;
if (thread_type == "discussion") {
icon_class = "icon-comments";
sr_text = "discussion";
} else if (endorsed) {
icon_class = "icon-ok";
sr_text = "answered question";
} else {
icon_class = "icon-question";
sr_text = "unanswered question";
}
%>
<span class="sr"><%= sr_text %></span>
<i class="icon <%= icon_class %>"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title"><%- title %></span>
<%
var labels = "";
......@@ -27,9 +43,6 @@ describe "DiscussionThreadListView", ->
}
%>
</div><div class="forum-nav-thread-wrapper-2">
<% if (endorsed) { %>
<span class="forum-nav-thread-endorsed"><i class="icon icon-ok"></i><span class="sr">Endorsed response</span></span>
<% } %>
<span class="forum-nav-thread-votes-count">+<%=
interpolate(
'%(votes_up_count)s%(span_sr_open)s votes %(span_close)s',
......@@ -84,9 +97,6 @@ describe "DiscussionThreadListView", ->
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">All Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-flagged">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-flag"></i>Flagged Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-following">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-star"></i>Posts I'm Following</a>
</li>
......@@ -98,7 +108,7 @@ describe "DiscussionThreadListView", ->
<ul class="forum-nav-browse-submenu">
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "child"}'
data-discussion-id="child"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Child</a>
......@@ -106,7 +116,7 @@ describe "DiscussionThreadListView", ->
</ul>
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "sibling"}'
data-discussion-id="sibling"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Sibling</a>
......@@ -115,7 +125,7 @@ describe "DiscussionThreadListView", ->
</li>
<li
class="forum-nav-browse-menu-item"
data-discussion-id='{"sort_key": null, "id": "other"}'
data-discussion-id="other"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Other Category</a>
......@@ -124,13 +134,21 @@ describe "DiscussionThreadListView", ->
</div>
<div class="forum-nav-thread-list-wrapper">
<div class="forum-nav-refine-bar">
<span class="forum-nav-sort">
<label class="forum-nav-filter-main">
<select class="forum-nav-filter-main-control">
<option value="all">Show all</option>
<option value="unread">Unread</option>
<option value="unanswered">Unanswered</option>
<option value="flagged">Flagged</option>
</select>
</label>
<label class="forum-nav-sort">
<select class="forum-nav-sort-control">
<option value="date">by recent activity</option>
<option value="comments">by most activity</option>
<option value="votes">by most votes</option>
</select>
</span>
</label>
</div>
</div>
<div class="search-alerts"></div>
......@@ -150,21 +168,21 @@ describe "DiscussionThreadListView", ->
<div class="forum-nav"></div>
"""
@threads = [
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "1",
title: "Thread1",
votes: {up_count: '20'},
comments_count: 1,
created_at: '2013-04-03T20:08:39Z',
}),
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "2",
title: "Thread2",
votes: {up_count: '42'},
comments_count: 2,
created_at: '2013-04-03T20:07:39Z',
}),
makeThreadWithProps({
DiscussionViewSpecHelper.makeThreadWithProps({
id: "3",
title: "Thread3",
votes: {up_count: '12'},
......@@ -179,20 +197,8 @@ describe "DiscussionThreadListView", ->
@view = new DiscussionThreadListView({collection: @discussion, el: $(".forum-nav")})
@view.render()
makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
unread_comments_count: 0,
comments_count: 0,
}
$.extend(thread, props)
renderSingleThreadWithProps = (props) ->
makeView(new Discussion([new Thread(makeThreadWithProps(props))])).render()
makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render()
makeView = (discussion) ->
return new DiscussionThreadListView(
......@@ -200,6 +206,31 @@ describe "DiscussionThreadListView", ->
collection: discussion
)
expectFilter = (filterVal) ->
$.ajax.andCallFake((params) ->
_.each(["unread", "unanswered", "flagged"], (paramName)->
if paramName == filterVal
expect(params.data[paramName]).toEqual(true)
else
expect(params.data[paramName]).toBeUndefined()
)
{always: ->}
)
describe "should filter correctly", ->
_.each(["all", "unread", "unanswered", "flagged"], (filterVal) ->
it "for #{filterVal}", ->
expectFilter(filterVal)
@view.$(".forum-nav-filter-main-control").val(filterVal).change()
expect($.ajax).toHaveBeenCalled()
)
it "search should clear filter", ->
expectFilter(null)
@view.$(".forum-nav-filter-main-control").val("flagged")
@view.searchFor("foobar")
expect(@view.$(".forum-nav-filter-main-control").val()).toEqual("all")
checkThreadsOrdering = (view, sort_order, type) ->
expect(view.$el.find(".forum-nav-thread").children().length).toEqual(3)
expect(view.$el.find(".forum-nav-thread:nth-child(1) .forum-nav-thread-title").text()).toEqual(sort_order[0])
......@@ -384,14 +415,21 @@ describe "DiscussionThreadListView", ->
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).not.toHaveBeenCalled()
describe "endorsed renders correctly", ->
it "when absent", ->
renderSingleThreadWithProps({})
expect($(".forum-nav-thread-endorsed").length).toEqual(0)
describe "post type renders correctly", ->
it "for discussion", ->
renderSingleThreadWithProps({thread_type: "discussion"})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-comments")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("discussion")
it "for answered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: true})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-ok")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question")
it "when present", ->
renderSingleThreadWithProps({endorsed: true})
expect($(".forum-nav-thread-endorsed").length).toEqual(1)
it "for unanswered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: false})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("icon-question")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("unanswered question")
describe "post labels render correctly", ->
beforeEach ->
......@@ -487,7 +525,7 @@ describe "DiscussionThreadListView", ->
expect(visibleItems).toEqual(expectedItems)
it "should be case-insensitive", ->
checkFilter("flagged", ["Flagged Discussions"])
checkFilter("other", ["Other Category"])
it "should match partial words", ->
checkFilter("ateg", ["Other Category"])
......
describe "DiscussionThreadShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
<div class="admin-pin discussion-pin notpinned" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-pushpin"></i>
<span class="pin-label">Pin Thread</span>
</div>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@threadData = {
id: "dummy",
user_id: user.id,
user_id: @user.id,
username: @user.get('username'),
course_id: $$course_id,
title: "dummy title",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: "42"}
votes: {up_count: 42},
thread_type: "discussion",
closed: false,
pinned: false,
type: "thread" # TODO - silly that this needs to be explicitly set
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($(".discussion-post"))
@view.setElement($("#fixture-element"))
@spyOn(@view, "convertMath")
it "renders the vote correctly", ->
describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true)
it "votes correctly via click", ->
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", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true)
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
describe "pinning", ->
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
expectPinnedRendered = (view, model) ->
pinned = model.get('pinned')
button = view.$el.find(".action-pin")
expect(button.hasClass("is-checked")).toBe(pinned)
expect(button.attr("aria-checked")).toEqual(pinned.toString())
describe "renderPinned", ->
describe "for an unpinned thread", ->
it "renders correctly when pinning is allowed", ->
it "renders the pinned state correctly", ->
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', false)
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', true)
@view.render()
expectPinnedRendered(@view, @thread)
it "exposes the pinning control only to authorized users", ->
@thread.updateInfo({ability: {can_openclose: false}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist()
@thread.updateInfo({ability: {can_openclose: true}})
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).not.toHaveClass("pinned")
expect(pinElem).toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pin Thread")
expect(pinElem).not.toHaveAttr("data-tooltip")
expect(pinElem).toHaveAttr("aria-pressed", "false")
# If pinning is not allowed, the pinning UI is not present, so no
# test is needed
describe "for a pinned thread", ->
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
it "handles events correctly", ->
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the closed label when appropriate', ->
expectOneElement(@view, '.post-label-closed', false)
@thread.set('closed', true)
expectOneElement(@view, '.post-label-closed')
it 'displays the pinned label when appropriate', ->
expectOneElement(@view, '.post-label-pinned', false)
@thread.set('pinned', true)
expectOneElement(@view, '.post-label-pinned')
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "author display", ->
beforeEach ->
@thread.set("pinned", true)
@thread.set('user_url', 'test_user_url')
it "renders correctly when unpinning is allowed", ->
@thread.updateInfo({ability: {can_openclose: true}})
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).toHaveClass("pinned")
expect(pinElem).not.toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned<span class='sr'>, click to unpin</span>")
expect(pinElem).toHaveAttr("data-tooltip", "Click to unpin")
expect(pinElem).toHaveAttr("aria-pressed", "true")
it "renders correctly when unpinning is not allowed", ->
@view.renderPinned()
pinElem = @view.$(".discussion-pin")
expect(pinElem.length).toEqual(1)
expect(pinElem).toHaveClass("pinned")
expect(pinElem).not.toHaveClass("notpinned")
expect(pinElem.find(".pin-label")).toHaveHtml("Pinned")
expect(pinElem).not.toHaveAttr("data-tooltip")
expect(pinElem).not.toHaveAttr("aria-pressed")
it "pinning button activates on appropriate events", ->
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".admin-pin")
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_user')
expect(element.find('a.username').attr('href')).toEqual('test_user_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders correctly for a student-authored thread", ->
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-authored thread", ->
@thread.set('community_ta_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-authored thread", ->
@thread.set('staff_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, true)
it "renders correctly for an anonymously-authored thread", ->
@thread.set('username', null)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
expect($el.find('a.username').length).toEqual(0)
expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)
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()
describe "DiscussionThreadView", ->
beforeEach ->
setFixtures(
"""
<script type="text/template" id="thread-template">
<article class="discussion-article">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</article>
</script>
<div class="thread-fixture"/>
"""
)
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
jasmine.Clock.useMock()
@threadData = {
id: "dummy"
}
@threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
@thread = new Thread(@threadData)
@view = new DiscussionThreadView({ model: @thread })
@view.setElement($(".thread-fixture"))
spyOn($, "ajax")
# Avoid unnecessary boilerplate
spyOn(@view.showView, "render")
spyOn(@view, "makeWmdEditor")
spyOn(DiscussionThreadView.prototype, "renderResponse")
spyOn(DiscussionThreadShowView.prototype, "convertMath")
spyOn(DiscussionContentView.prototype, "makeWmdEditor")
spyOn(ThreadResponseView.prototype, "renderShowView")
describe "response count and pagination", ->
renderWithContent = (view, content) ->
DiscussionViewSpecHelper.setNextResponseContent(content)
view.render()
jasmine.Clock.tick(100)
assertRenderedCorrectly = (view, countText, displayCountText, buttonText) ->
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toBeGreaterThan(0)
content.each (i, elem) ->
expect($(elem).is(":visible")).toEqual(visible)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".forum-thread-expand", not expanded)
assertContentVisible(view, ".forum-thread-collapse", expanded)
assertResponseCountAndPaginationCorrect = (view, countText, displayCountText, buttonText) ->
expect(view.$el.find(".response-count").text()).toEqual(countText)
if displayCountText
expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText)
......@@ -43,38 +40,149 @@ describe "DiscussionThreadView", ->
else
expect(view.$el.find(".load-response-button").length).toEqual(0)
describe "tab mode", ->
beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "tab"})
describe "response count and pagination", ->
it "correctly render for a thread with no responses", ->
renderWithContent(@view, {resp_total: 0, children: []})
assertRenderedCorrectly(@view, "0 responses", null, null)
assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null)
it "correctly render for a thread with one response", ->
renderWithContent(@view, {resp_total: 1, children: [{}]})
assertRenderedCorrectly(@view, "1 response", "Showing all responses", null)
assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null)
it "correctly render for a thread with one additional page", ->
renderWithContent(@view, {resp_total: 2, children: [{}]})
assertRenderedCorrectly(@view, "2 responses", "Showing first response", "Load all responses")
assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses")
it "correctly render for a thread with multiple additional pages", ->
renderWithContent(@view, {resp_total: 111, children: [{}, {}]})
assertRenderedCorrectly(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
describe "on clicking the load more button", ->
beforeEach ->
renderWithContent(@view, {resp_total: 5, children: [{}]})
assertRenderedCorrectly(@view, "5 responses", "Showing first response", "Load all responses")
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "5 responses", "Showing all responses", null)
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null)
it "correctly re-render when one page remains", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 42, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "42 responses", "Showing first 3 responses", "Load all responses")
assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses")
it "correctly re-render when multiple pages remain", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 111, children: [{}, {}]})
@view.$el.find(".load-response-button").click()
assertRenderedCorrectly(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
describe "inline mode", ->
beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "inline"})
describe "render", ->
it "shows content that should be visible when collapsed", ->
@view.render()
assertExpandedContentVisible(@view, false)
it "does not render any responses by default", ->
@view.render()
expect($.ajax).not.toHaveBeenCalled()
expect(@view.$el.find(".responses li").length).toEqual(0)
describe "expand/collapse", ->
it "shows/hides appropriate content", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
assertExpandedContentVisible(@view, true)
@view.collapse()
assertExpandedContentVisible(@view, false)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longBody = new Array(100).join("test ")
expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140)
@thread.set("body", longBody)
@view.render()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.reset()
@view.expand()
expect($(".post-body").text()).toEqual(longBody)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.reset()
@view.collapse()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
describe "for question threads", ->
beforeEach ->
@thread.set("thread_type", "question")
@view = new DiscussionThreadView(
{model: @thread, el: $("#fixture-element"), mode: "tab"}
)
renderTestCase = (view, numEndorsed, numNonEndorsed) ->
generateContent = (idStart, idEnd) ->
_.map(_.range(idStart, idEnd), (i) -> {"id": "#{i}"})
renderWithContent(
view,
{
endorsed_responses: generateContent(0, numEndorsed),
non_endorsed_responses: generateContent(numEndorsed, numEndorsed + numNonEndorsed),
non_endorsed_resp_total: numNonEndorsed
}
)
expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed)
expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed)
assertResponseCountAndPaginationCorrect(
view,
ngettext(
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}response",
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}responses",
numNonEndorsed
)
if numNonEndorsed then "Showing all responses" else null,
null
)
_.each({"no": 0, "one": 1, "many": 5}, (numEndorsed, endorsedDesc) ->
_.each({"no": 0, "one": 1, "many": 5}, (numNonEndorsed, nonEndorsedDesc) ->
it "renders correctly with #{endorsedDesc} marked answer(s) and #{nonEndorsedDesc} response(s)", ->
renderTestCase(@view, numEndorsed, numNonEndorsed)
)
)
it "handles pagination correctly", ->
renderWithContent(
@view,
{
endorsed_responses: [{id: "1"}, {id: "2"}],
non_endorsed_responses: [{id: "3"}, {id: "4"}, {id: "5"}],
non_endorsed_resp_total: 42
}
)
DiscussionViewSpecHelper.setNextResponseContent({
# Add an endorsed response; it should be rendered
endorsed_responses: [{id: "1"}, {id: "2"}, {id: "6"}],
non_endorsed_responses: [{id: "7"}, {id: "8"}, {id: "9"}],
non_endorsed_resp_total: 41
})
@view.$el.find(".load-response-button").click()
expect($(".js-marked-answer-list .discussion-response").length).toEqual(3)
expect($(".js-response-list .discussion-response").length).toEqual(6)
assertResponseCountAndPaginationCorrect(
@view,
"41 other responses",
"Showing first 6 responses",
"Load all responses"
)
class @DiscussionViewSpecHelper
@expectVoteRendered = (view, voted) ->
button = view.$el.find(".vote-btn")
if voted
expect(button.hasClass("is-cast")).toBe(true)
expect(button.attr("aria-pressed")).toEqual("true")
expect(button.attr("data-tooltip")).toEqual("remove vote")
expect(button.text()).toEqual("43 votes (click to remove your vote)")
else
expect(button.hasClass("is-cast")).toBe(false)
expect(button.attr("aria-pressed")).toEqual("false")
expect(button.attr("data-tooltip")).toEqual("vote")
expect(button.text()).toEqual("42 votes (click to vote)")
@makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
thread_type: "discussion",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
unread_comments_count: 0,
comments_count: 0,
abuse_flaggers: [],
body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
}
$.extend(thread, props)
@expectVoteRendered = (view, model, user) ->
button = view.$el.find(".action-vote")
expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$")
@checkRenderVote = (view, model) ->
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, true)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
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)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "42"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
expect(params.url.toString()).toEqual(expectedUrl)
return deferred
)
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
view.render()
view.$el.find(".action-vote").trigger(event)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
@checkToggleVote = (view, model) ->
event = {preventDefault: ->}
spyOn(event, "preventDefault")
spyOn(view, "vote").andCallFake(() -> window.user.vote(model))
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model))
expect(window.user.voted(model)).toBe(false)
view.toggleVote(event)
expect(view.vote).toHaveBeenCalled()
expect(view.unvote).not.toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(1)
view.vote.reset()
view.unvote.reset()
expect(window.user.voted(model)).toBe(true)
view.toggleVote(event)
expect(view.vote).not.toHaveBeenCalled()
expect(view.unvote).toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(2)
deferred.resolve()
@checkUpvote = (view, model, user, event) ->
expect(model.id in user.get('upvoted_ids')).toBe(false)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
@checkUnvote = (view, model, user, event) ->
user.vote(model)
expect(model.id in user.get('upvoted_ids')).toBe(true)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(user.get('upvoted_ids')).toEqual([])
expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
@checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc)
......@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper
expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".vote-btn")
@checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) ->
$.ajax.andCallFake(
......
......@@ -3,101 +3,53 @@ describe "NewPostView", ->
beforeEach ->
setFixtures(
"""
<article class="new-post-article" style="display: block;">
<div class="inner-wrapper">
<form class="new-post-form">
<div class="left-column" >
<div class="discussion-body">
<div class="discussion-column">
<article class="new-post-article" style="display: block;"></article>
</div>
</form>
</div>
</article>
<script aria-hidden="true" type="text/template" id="new-post-tab-template">
<div class="inner-wrapper">
<form class="new-post-form">
<div class="left-column">
'<%= topic_dropdown_html %>
'<%= options_html %>'
<script aria-hidden="true" type="text/template" id="new-post-template">
<form class="forum-new-post-form">
<% if (mode=="tab") { %>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
Topic Area:
</span>
<div class="field-input post-topic">
<a href="#" class="post-topic-button">
<span class="sr">${_("Discussion topics; current selection is: ")}</span>
<span class="js-selected-topic"></span>
<span class="drop-arrow" aria-hidden="true">▾</span>
</a>
<div class="topic-menu-wrapper">
<ul class="topic-menu" role="menu"><%= topics_html %></ul>
</div>
</form>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-inline-template">
<div class="inner-wrapper">
<div class="new-post-form-errors">
</div>
<form class="new-post-form">
<%= editor_html %>
<%= options_html %>
</form>
</div>
<% } %>
<select class="js-group-select">
<option value="">All Groups</option>
<option value="1">Group 1</option>
<option value="2">Group 2</option>
</select>
</form>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem"><a href="#" class="topic" data-discussion_id="<%- id %>" aria-describedby="topic-name-span-<%- id %>" cohorted="<%- is_cohorted %>"><%- text %></a></li>
<li role="menuitem">
<a href="#" class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_cohorted %>"><%- text %></a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem">
<a href="#"><span class="category-menu-span"><%- text %></span></a>
<ul role="menu"><%= entries %></ul>
<span class="topic-title"><%- text %></span>
<ul role="menu" class="topic-submenu"><%= entries %></ul>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-topic-dropdown-template">
<span class="topic-dropdown-label" id="topic-dropdown-label">Create new post about:</span>
<div class="form-topic-drop">
<a href="#" aria-labelledby="topic-dropdown-label" class="topic_dropdown_button">${_("Show All Discussions")}<span class="drop-arrow" aria-hidden="true">▾</span></a>
<div class="topic_menu_wrapper">
<div class="topic_menu_search" role="menu">
<label class="sr" for="browse-topic-newpost">Filter List</label>
<input type="text" id="browse-topic-newpost" class="form-topic-drop-search-input" placeholder="Filter discussion areas">
</div>
<ul class="topic_menu" role="menu"><%= topics_html %></ul>
</div>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-options-template">
<div class="options">
<input type="checkbox" name="follow" class="discussion-follow" id="new-post-follow" checked><label for="new-post-follow">follow this post</label>
<% if (allow_anonymous) { %>
<br>
<input type="checkbox" name="anonymous" class="discussion-anonymous" id="new-post-anonymous">
<label for="new-post-anonymous">post anonymously</label>
<% } %>
<% if (allow_anonymous_to_peers) { %>
<br>
<input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers">
<label for="new-post-anonymous-to-peers">post anonymously to classmates</label>
<% } %>
<% if (cohort_options) { %>
<div class="form-group-label choose-cohort">
## Translators: This labels the selector for which group of students can view a post
Make visible to:
<select class="group-filter-select new-post-group" name="group_id">
<option value="">All Groups</option>
<% _.each(cohort_options, function(opt) { %>
<option value="<%= opt.value %>" <% if (opt.selected) { %>selected<% } %>><%- opt.text %></option>
<% }); %>
</select>
</div>
<% } %>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-editor-template">
<div class="form-row">
<label class="sr" for="new-post-title">new post title</label>
<input type="text" id="new-post-title" class="new-post-title" name="title" placeholder="Title">
</div>
<div class="form-row">
<div class="new-post-body" name="body" placeholder="Enter your question or comment…"></div>
</div>
<input type="submit" id="new-post-submit" class="submit" value="Add post">
<a href="#" class="new-post-cancel">Cancel</a>
</script>
"""
)
window.$$course_id = "edX/999/test"
......@@ -106,10 +58,7 @@ describe "NewPostView", ->
describe "Drop down works correct", ->
beforeEach ->
@view = new NewPostView(
el: $(".new-post-article"),
collection: @discussion,
course_settings: new DiscussionCourseSettings({
@course_settings = new DiscussionCourseSettings({
"category_map": {
"subcategories": {
"Basic Question Types": {
......@@ -129,7 +78,11 @@ describe "NewPostView", ->
},
"allow_anonymous": true,
"allow_anonymous_to_peers": true
}),
})
@view = new NewPostView(
el: $(".new-post-article"),
collection: @discussion,
course_settings: @course_settings,
mode: "tab"
)
@view.render()
......@@ -140,16 +93,16 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width + 1
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
expect(complete_text+' ▾').toEqual(dropdown_text)
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(complete_text).toEqual(dropdown_text)
it "completely show just sub-category", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width - 10
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("…")).toEqual(0)
expect(dropdown_text).toContain(@selected_option_text)
......@@ -158,8 +111,8 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = selected_text_width - parent_width
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("…")).toEqual(0)
expect(dropdown_text.lastIndexOf("…")).toBeGreaterThan(0)
......@@ -167,10 +120,49 @@ describe "NewPostView", ->
complete_text = @parent_category_text + " / " + @selected_option_text
selected_text_width = @view.getNameWidth(complete_text)
@view.maxNameWidth = @view.getNameWidth(@selected_option_text) + 100
@view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click()
dropdown_text = @view.$el.find(".form-topic-drop > a").text()
@view.$el.find( "a.topic-title" ).first().click()
dropdown_text = @view.$el.find(".js-selected-topic").text()
expect(dropdown_text.indexOf("/ span>")).toEqual(-1)
describe "cohort selector", ->
renderWithCohortedTopics = (course_settings, view, isCohortedFirst) ->
course_settings.set(
"category_map",
{
"children": if isCohortedFirst then ["Cohorted", "Non-Cohorted"] else ["Non-Cohorted", "Cohorted"],
"entries": {
"Non-Cohorted": {
"sort_key": null,
"is_cohorted": false,
"id": "non-cohorted"
},
"Cohorted": {
"sort_key": null,
"is_cohorted": true,
"id": "cohorted"
}
}
}
)
view.render()
expectCohortSelectorEnabled = (view, enabled) ->
expect(view.$(".js-group-select").prop("disabled")).toEqual(not enabled)
if not enabled
expect(view.$(".js-group-select option:selected").attr("value")).toEqual("")
it "is disabled with non-cohorted default topic and enabled by selecting cohorted topic", ->
renderWithCohortedTopics(@course_settings, @view, false)
expectCohortSelectorEnabled(@view, false)
@view.$("a.topic-title[data-discussion-id=cohorted]").click()
expectCohortSelectorEnabled(@view, true)
it "is enabled with cohorted default topic and disabled by selecting non-cohorted topic", ->
renderWithCohortedTopics(@course_settings, @view, true)
expectCohortSelectorEnabled(@view, true)
@view.$("a.topic-title[data-discussion-id=non-cohorted]").click()
expectCohortSelectorEnabled(@view, false)
it "posts to the correct URL", ->
topicId = "test_topic"
spyOn($, "ajax").andCallFake(
......@@ -189,5 +181,5 @@ describe "NewPostView", ->
topicId: topicId
)
view.render()
view.$(".new-post-form").submit()
view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()
......@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in
setFixtures """
<ol class="responses"></ol>
<script id="response-comment-show-template" type="text/template">
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label"></span></div>
<div style="display:none" class="discussion-delete-comment action-delete" data-role="comment-delete" data-tooltip="Delete Comment" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-remove"></i><span class="sr delete-label">Delete Comment</span></div>
<div style="display:none" class="discussion-edit-comment action-edit" data-tooltip="Edit Comment" role="button" tabindex="0">
<i class="icon icon-pencil"></i><span class="sr">Edit Comment</span></div>
<p class="posted-details">&ndash;posted <span class="timeago" title="<%- created_at %>"><%- created_at %></span> by
<% if (obj.username) { %>
<a href="<%- user_url %>" class="profile-link"><%- username %></a>
<% } else {print('anonymous');} %>
</p>
</div>
</script>
"""
DiscussionSpecHelper.setUnderscoreFixtures()
# set up a model for a new Comment
@comment = new Comment {
......@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', ->
beforeEach ->
spyOn(@view, 'renderAttrs')
spyOn(@view, 'markAsStaff')
it 'produces the correct HTML', ->
@view.render()
expect(@view.el.innerHTML).toContain('"discussion-flag-abuse notflagged"')
it 'can be flagged for abuse', ->
@comment.flagAbuse()
......@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', ->
@view.bind "comment:edit", triggerTarget
@view.edit()
expect(triggerTarget).toHaveBeenCalled()
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
......@@ -10,19 +10,9 @@ describe 'ResponseCommentView', ->
abuse_flaggers: ['123']
roles: ['Student']
}
setFixtures """
<script id="response-comment-show-template" type="text/template">
<div id="response-comment-show-div"/>
</script>
<script id="response-comment-edit-template" type="text/template">
<div id="response-comment-edit-div">
<div class="edit-comment-body"><textarea/></div>
<ul class="edit-comment-form-errors"/>
</div>
</script>
<div id="response-comment-fixture"/>
"""
@view = new ResponseCommentView({ model: @comment, el: $("#response-comment-fixture") })
DiscussionSpecHelper.setUnderscoreFixtures()
@view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor")
@view.render()
......@@ -95,8 +85,7 @@ describe 'ResponseCommentView', ->
expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", makeEventSpy()
expect(@view.edit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(1)
expect(@view.$("#response-comment-edit-div").length).toEqual(0)
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', ->
......@@ -107,8 +96,7 @@ describe 'ResponseCommentView', ->
expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(0)
expect(@view.$("#response-comment-edit-div").length).toEqual(1)
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', ->
......@@ -135,6 +123,8 @@ describe 'ResponseCommentView', ->
describe 'update', ->
beforeEach ->
@updatedBody = "updated body"
# Markdown code creates the editor, so we simulate that here
@view.$el.find(".edit-comment-body").html($("<textarea></textarea>"))
@view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit')
spyOn($, "ajax").andCallFake(
......
describe "ThreadResponseShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
</div>
"""
)
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@thread = new Thread({"thread_type": "discussion"})
@commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
endorsed: false,
abuse_flaggers: [],
votes: {up_count: "42"}
votes: {up_count: 42},
type: "comment"
}
@comment = new Comment(@commentData)
@view = new ThreadResponseShowView({ model: @comment })
@view.setElement($(".discussion-post"))
@comment.set("thread", @thread)
@view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") })
# Avoid unnecessary boilerplate
spyOn(ThreadResponseShowView.prototype, "convertMath")
@view.render()
describe "voting", ->
it "renders the vote correctly", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @comment, @commentData, true)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "renders endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": "test_endorser",
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"marked as answer less than a minute ago by " + endorsement.username
)
it "renders anonymous endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "renders endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": "test_endorser",
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"endorsed less than a minute ago by " + endorsement.username
)
it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "re-renders correctly when endorsement changes", ->
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set("thread_type", "question")
@view.render()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
it "allows a moderator to mark an answer in a question thread", ->
DiscussionUtil.loadRoles({"Moderator": parseInt(window.user.id)})
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "allows the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "does not allow the author of a discussion thread to endorse", ->
@thread.set({
"thread_type": "discussion",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-endorse")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
it "does not allow a student who is not the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "endorser display", ->
beforeEach ->
@comment.set('endorsement', {
"username": "test_endorser",
"time": new Date().toISOString()
})
spyOn(DiscussionUtil, 'urlFor').andReturn('test_endorser_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_endorser')
expect(element.find('a.username').attr('href')).toEqual('test_endorser_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders nothing when the response has not been endorsed", ->
@comment.set('endorsement', null)
expect(@view.getEndorserDisplay()).toBeNull()
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @commentData, true)
it "renders correctly for a student-endorsed response", ->
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, false)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @comment)
it "renders correctly for a community TA-endorsed response", ->
spyOn(DiscussionUtil, 'isTA').andReturn(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, true, false)
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
it "renders correctly for a staff-endorsed response", ->
spyOn(DiscussionUtil, 'isStaff').andReturn(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, true)
describe 'ThreadResponseView', ->
beforeEach ->
setFixtures """
<script id="thread-response-template" type="text/template">
<div/>
</script>
<div id="thread-response-fixture"/>
"""
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@response = new Comment {
children: [{}, {}]
}
@view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")})
@view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render")
describe 'renderComments', ->
it 'hides "show comments" link if collapseComments is not set', ->
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [] }
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).not.toBeVisible()
expect(@view.$(".action-show-comments")).toBeVisible()
@view.$(".action-show-comments").click()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView()
......
......@@ -9,7 +9,6 @@ if Backbone?
actions:
editable: '.admin-edit'
can_reply: '.discussion-reply'
can_endorse: '.admin-endorse'
can_delete: '.admin-delete'
can_openclose: '.admin-openclose'
......@@ -21,6 +20,9 @@ if Backbone?
can: (action) ->
(@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) ->
if info
@set('ability', info.ability)
......@@ -106,13 +108,21 @@ if Backbone?
@get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @
isFlagged: ->
user = DiscussionUtil.getUser()
flaggers = @get("abuse_flaggers")
user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0))
incrementVote: (increment) ->
newVotes = _.clone(@get("votes"))
newVotes.up_count = newVotes.up_count + increment
@set("votes", newVotes)
vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1
@trigger "change", @
@incrementVote(1)
unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1
@trigger "change", @
@incrementVote(-1)
class @Thread extends @Content
urlMappers:
......@@ -187,6 +197,13 @@ if Backbone?
count += comment.getCommentsCount() + 1
count
canBeEndorsed: =>
user_id = window.user.get("id")
user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id)
)
class @Comments extends Backbone.Collection
model: Comment
......
......@@ -34,6 +34,8 @@ if Backbone?
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode
when 'search'
url = DiscussionUtil.urlFor 'search'
......@@ -43,9 +45,6 @@ if Backbone?
data['commentable_ids'] = options.commentable_ids
when 'all'
url = DiscussionUtil.urlFor 'threads'
when 'flagged'
data['flagged'] = true
url = DiscussionUtil.urlFor 'search'
when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']
......
class @DiscussionFilter
# TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
# for use with a very similar category dropdown in the New Post form. The two menus' implementations
# should be merged into a single reusable view.
@filterDrop: (e) ->
$drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper')
$drop = $(e.target).parents('.topic-menu-wrapper')
query = $(e.target).val()
$items = $drop.find('a')
$items = $drop.find('.topic-menu-item')
if(query.length == 0)
$items.removeClass('hidden')
......@@ -10,19 +15,14 @@ class @DiscussionFilter
$items.addClass('hidden')
$items.each (i) ->
thisText = $(this).not('.unread').text()
$(this).parents('ul').siblings('a').not('.unread').each (i) ->
thisText = thisText + ' ' + $(this).text();
test = true
terms = thisText.split(' ')
if(thisText.toLowerCase().search(query.toLowerCase()) == -1)
test = false
path = $(this).parents(".topic-menu-item").andSelf()
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')
# show children
$(this).parent().find('a').removeClass('hidden');
$(this).find('.topic-menu-item').removeClass('hidden');
# show parents
$(this).parents('ul').siblings('a').removeClass('hidden');
$(this).parents('.topic-menu-item').removeClass('hidden');
......@@ -7,7 +7,7 @@ if Backbone?
"click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .new-post-cancel": "hideNewPost"
"click .cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
......@@ -101,7 +101,7 @@ if Backbone?
@newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline"
_.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView(
......@@ -124,7 +124,7 @@ if Backbone?
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread
threadView = new DiscussionThreadView el: article, model: thread, mode: "inline"
threadView.render()
@threadviews.unshift threadView
......
......@@ -25,7 +25,7 @@ if Backbone?
@newPostView.render()
$('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
$('.new-post-cancel').bind "click", @hideNewPost
@newPostView.$('.cancel').bind "click", @hideNewPost
allThreads: ->
@nav.updateSidebar()
......@@ -45,8 +45,12 @@ if Backbone?
if(@main)
@main.cleanup()
@main.undelegateEvents()
unless($(".forum-content").is(":visible"))
$(".forum-content").fadeIn()
if(@newPost.is(":visible"))
@newPost.fadeOut()
@main = new DiscussionThreadView(el: $(".discussion-column"), model: @thread)
@main = new DiscussionThreadView(el: $(".forum-content"), model: @thread, mode: "tab")
@main.render()
@main.on "thread:responses:rendered", =>
@nav.updateSidebar()
......@@ -59,8 +63,17 @@ if Backbone?
@navigate("", trigger: true)
showNewPost: (event) =>
@newPost.slideDown(300)
$('.forum-content').fadeOut(
duration: 200
complete: =>
@newPost.fadeIn(200)
$('.new-post-title').focus()
)
hideNewPost: (event) =>
@newPost.slideUp(300)
@newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200)
)
......@@ -21,15 +21,14 @@ class @DiscussionUtil
@setUser: (user) ->
@user = user
@getUser: () ->
@user
@loadRoles: (roles)->
@roleIds = roles
@loadFlagModerator: (what)->
@isFlagModerator = ((what=="True") or (what == 1))
@loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles"))
@loadFlagModerator($("#discussion-container").data("flag-moderator"))
@isStaff: (user_id) ->
user_id ?= @user?.id
......@@ -41,6 +40,9 @@ class @DiscussionUtil
ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) ->
for id, info of infos
Content.getContent(id).updateInfo(info)
......@@ -159,6 +161,13 @@ class @DiscussionUtil
params["$loading"].loaded()
return request
@updateWithUndo: (model, updates, safeAjaxParams, errorMsg) ->
if errorMsg
safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg)
undo = _.pick(model.attributes, _.keys(updates))
model.set(updates)
@safeAjax(safeAjaxParams).fail(() -> model.set(undo))
@bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ')
......@@ -167,7 +176,7 @@ class @DiscussionUtil
@formErrorHandler: (errorsField) ->
(xhr, textStatus, error) ->
makeErrorElem = (message) ->
$("<li>").addClass("new-post-form-error").html(message)
$("<li>").addClass("post-error").html(message)
errorsField.empty().show()
if xhr.status == 400
response = JSON.parse(xhr.responseText)
......
......@@ -8,40 +8,6 @@ if Backbone?
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
attrRenderer:
endorsed: (endorsed) ->
if endorsed
@$(".action-endorse").show().addClass("is-endorsed")
else
if @model.get('ability')?.can_endorse
@$(".action-endorse").show()
else
@$(".action-endorse").hide()
@$(".action-endorse").removeClass("is-endorsed")
closed: (closed) ->
return if not @$(".action-openclose").length
return if not @$(".post-status-closed").length
if closed
@$(".post-status-closed").show()
@$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Close"), gettext("Open")))
@$(".discussion-reply-new").hide()
else
@$(".post-status-closed").hide()
@$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Open"), gettext("Close")))
@$(".discussion-reply-new").show()
voted: (voted) ->
votes_point: (votes_point) ->
comments_count: (comments_count) ->
subscribed: (subscribed) ->
if subscribed
@$(".dogear").addClass("is-followed").attr("aria-checked", "true")
else
@$(".dogear").removeClass("is-followed").attr("aria-checked", "false")
ability: (ability) ->
for action, selector of @abilityRenderer
if not ability[action]
......@@ -51,23 +17,22 @@ if Backbone?
abilityRenderer:
editable:
enable: -> @$(".action-edit").closest("li").show()
disable: -> @$(".action-edit").closest("li").hide()
enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden")
can_delete:
enable: -> @$(".action-delete").closest("li").show()
disable: -> @$(".action-delete").closest("li").hide()
can_endorse:
enable: -> @$(".action-delete").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-delete").closest(".actions-item").addClass("is-hidden")
can_openclose:
enable: ->
@$(".action-endorse").show().css("cursor", "auto")
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").removeClass("is-hidden")
)
disable: ->
@$(".action-endorse").css("cursor", "default")
if not @model.get('endorsed')
@$(".action-endorse").hide()
else
@$(".action-endorse").show()
can_openclose:
enable: -> @$(".action-openclose").closest("li").show()
disable: -> @$(".action-openclose").closest("li").hide()
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").addClass("is-hidden")
)
renderPartialAttrs: ->
for attr, value of @model.changedAttributes()
......@@ -79,15 +44,6 @@ if Backbone?
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
$: (selector) ->
@$local.find(selector)
initLocal: ->
@$local = @$el.children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
makeWmdEditor: (cls_identifier) =>
if not @$el.find(".wmd-panel").length
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier
......@@ -103,116 +59,238 @@ if Backbone?
initialize: ->
@initLocal()
@model.bind('change', @renderPartialAttrs, @)
@listenTo(@model, "change:endorsed", =>
if @model instanceof Comment
@trigger("comment:endorse")
)
class @DiscussionContentShowView extends DiscussionContentView
events:
_.reduce(
[
[".action-follow", "toggleFollow"],
[".action-answer", "toggleEndorse"],
[".action-endorse", "toggleEndorse"],
[".action-vote", "toggleVote"],
[".action-more", "toggleSecondaryActions"],
[".action-pin", "togglePin"],
[".action-edit", "edit"],
[".action-delete", "_delete"],
[".action-report", "toggleReport"],
[".action-close", "toggleClose"],
],
(obj, event) =>
selector = event[0]
funcName = event[1]
obj["click #{selector}"] = (event) -> @[funcName](event)
obj["keydown #{selector}"] = (event) -> DiscussionUtil.activateOnSpace(event, @[funcName])
obj
,
{}
)
updateButtonState: (selector, checked) =>
$button = @$(selector)
$button.toggleClass("is-checked", checked)
$button.attr("aria-checked", checked)
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
subscribed: (subscribed) ->
@updateButtonState(".action-follow", subscribed)
endorsed: (endorsed) ->
selector = if @model.get("thread").get("thread_type") == "question" then ".action-answer" else ".action-endorse"
@updateButtonState(selector, endorsed)
$button = @$(selector)
$button.closest(".actions-item").toggleClass("is-hidden", not @model.canBeEndorsed())
$button.toggleClass("is-checked", endorsed)
votes: (votes) ->
selector = ".action-vote"
@updateButtonState(selector, window.user.voted(@model))
button = @$el.find(selector)
numVotes = votes.up_count
button.find(".js-sr-vote-count").html(
interpolate(
ngettext("currently %(numVotes)s vote", "currently %(numVotes)s votes", numVotes),
{numVotes: numVotes},
true
)
)
button.find(".js-visual-vote-count").html(
interpolate(
ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes),
{numVotes: numVotes},
true
)
)
pinned: (pinned) ->
@updateButtonState(".action-pin", pinned)
@$(".post-label-pinned").toggleClass("is-hidden", not pinned)
toggleFollowing: (event) =>
abuse_flaggers: (abuse_flaggers) ->
flagged = @model.isFlagged()
@updateButtonState(".action-report", flagged)
@$(".post-label-reported").toggleClass("is-hidden", not flagged)
closed: (closed) ->
@updateButtonState(".action-close", closed)
@$(".post-label-closed").toggleClass("is-hidden", not closed)
})
toggleSecondaryActions: (event) =>
event.preventDefault()
$elem = $(event.target)
url = null
if not @model.get('subscribed')
@model.follow()
url = @model.urlFor("follow")
event.stopPropagation()
@secondaryActionsExpanded = !@secondaryActionsExpanded
@$(".action-more").toggleClass("is-expanded", @secondaryActionsExpanded)
@$(".actions-dropdown").
toggleClass("is-expanded", @secondaryActionsExpanded).
attr("aria-expanded", @secondaryActionsExpanded)
if @secondaryActionsExpanded
if event.type == "keydown"
@$(".action-list-item:first").focus()
$("body").on("click", @toggleSecondaryActions)
$("body").on("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").on("blur", @handleSecondaryActionBlur)
else
@model.unfollow()
url = @model.urlFor("unfollow")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
toggleFlagAbuse: (event) =>
$("body").off("click", @toggleSecondaryActions)
$("body").off("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").off("blur", @handleSecondaryActionBlur)
handleSecondaryActionEscape: (event) =>
if event.keyCode == 27 # Esc
@toggleSecondaryActions(event)
@$(".action-more").focus()
handleSecondaryActionBlur: (event) =>
setTimeout(
=>
if @secondaryActionsExpanded && @$(".actions-dropdown :focus").length == 0
@toggleSecondaryActions(event)
,
10
)
toggleFollow: (event) =>
event.preventDefault()
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@unFlagAbuse()
is_subscribing = not @model.get("subscribed")
url = @model.urlFor(if is_subscribing then "follow" else "unfollow")
if is_subscribing
msg = gettext("We had some trouble subscribing you to this thread. Please try again.")
else
@flagAbuse()
flagAbuse: =>
url = @model.urlFor("flagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
###
note, we have to clone the array in order to trigger a change event
###
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.push(window.user.id)
@model.set('abuse_flaggers', temp_array)
unFlagAbuse: =>
url = @model.urlFor("unFlagAbuse")
DiscussionUtil.safeAjax
$elem: @$(".discussion-flag-abuse")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
temp_array = _.clone(@model.get('abuse_flaggers'));
temp_array.pop(window.user.id)
# if you're an admin, clear this
if DiscussionUtil.isFlagModerator
temp_array = []
@model.set('abuse_flaggers', temp_array)
renderVote: =>
button = @$el.find(".vote-btn")
voted = window.user.voted(@model)
voteNum = @model.get("votes")["up_count"]
button.toggleClass("is-cast", voted)
button.attr("aria-pressed", voted)
button.attr("data-tooltip", if voted then gettext("remove vote") else gettext("vote"))
buttonTextFmt =
if voted
ngettext(
"vote (click to remove your vote)",
"votes (click to remove your vote)",
voteNum
msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{"subscribed": is_subscribing},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleEndorse: (event) =>
event.preventDefault()
is_endorsing = not @model.get("endorsed")
url = @model.urlFor("endorse")
updates =
endorsed: is_endorsing
endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), time: new Date().toISOString()} else null
if @model.get('thread').get('thread_type') == 'question'
if is_endorsing
msg = gettext("We had some trouble marking this response as an answer. Please try again.")
else
msg = gettext("We had some trouble removing this response as an answer. Please try again.")
else
if is_endorsing
msg = gettext("We had some trouble marking this response endorsed. Please try again.")
else
msg = gettext("We had some trouble removing this endorsement. Please try again.")
beforeFunc = () => @trigger("comment:endorse")
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", data: {endorsed: is_endorsing}, beforeSend: beforeFunc, $elem: $(event.currentTarget)},
msg
).always(@trigger("comment:endorse")) # ensures UI components get updated to the correct state when ajax completes
toggleVote: (event) =>
event.preventDefault()
user = DiscussionUtil.getUser()
is_voting = not user.voted(@model)
url = @model.urlFor(if is_voting then "upvote" else "unvote")
updates =
upvoted_ids: (if is_voting then _.union else _.difference)(user.get('upvoted_ids'), [@model.id])
DiscussionUtil.updateWithUndo(
user,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
gettext("We had some trouble saving your vote. Please try again.")
).done(() => if is_voting then @model.vote() else @model.unvote())
togglePin: (event) =>
event.preventDefault()
is_pinning = not @model.get("pinned")
url = @model.urlFor(if is_pinning then "pinThread" else "unPinThread")
if is_pinning
msg = gettext("We had some trouble pinning this thread. Please try again.")
else
ngettext(
"vote (click to vote)",
"votes (click to vote)",
voteNum
msg = gettext("We had some trouble unpinning this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{pinned: is_pinning},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
buttonTextFmt = "%(voteNum)s%(startSrSpan)s " + buttonTextFmt + "%(endSrSpan)s"
buttonText = interpolate(
buttonTextFmt,
{voteNum: voteNum, startSrSpan: "<span class='sr'>", endSrSpan: "</span>"},
true
toggleReport: (event) =>
event.preventDefault()
if @model.isFlagged()
is_flagging = false
msg = gettext("We had some trouble removing your flag on this post. Please try again.")
else
is_flagging = true
msg = gettext("We had some trouble reporting this post. Please try again.")
url = @model.urlFor(if is_flagging then "flagAbuse" else "unFlagAbuse")
updates =
abuse_flaggers: (if is_flagging then _.union else _.difference)(@model.get("abuse_flaggers"), [DiscussionUtil.getUser().id])
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
button.html("<span class='plus-icon'/>" + buttonText)
toggleVote: (event) =>
toggleClose: (event) =>
event.preventDefault()
if window.user.voted(@model)
@unvote()
is_closing = not @model.get('closed')
if is_closing
msg = gettext("We had some trouble closing this thread. Please try again.")
else
msg = gettext("We had some trouble reopening this thread. Please try again.")
updates = {closed: is_closing}
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: @model.urlFor("close"), type: "POST", data: updates, $elem: $(event.currentTarget)},
msg
)
getAuthorDisplay: ->
_.template($("#post-user-display-template").html())(
username: @model.get('username') || null
user_url: @model.get('user_url')
is_community_ta: @model.get('community_ta_authored')
is_staff: @model.get('staff_authored')
)
getEndorserDisplay: ->
endorsement = @model.get('endorsement')
if endorsement and endorsement.username
_.template($("#post-user-display-template").html())(
username: endorsement.username
user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id)
is_community_ta: DiscussionUtil.isTA(endorsement.user_id)
is_staff: DiscussionUtil.isStaff(endorsement.user_id)
)
else
@vote()
vote: =>
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: =>
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
null
......@@ -10,6 +10,7 @@ if Backbone?
"change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: ->
......@@ -75,7 +76,7 @@ if Backbone?
#TODO fix this entire chain of events
addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id")
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id").id == commentable_id)
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id)
@setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id')
......@@ -173,7 +174,7 @@ if Backbone?
loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus()
options = {}
options = {filter: @filter}
switch @mode
when 'search'
options.search_text = @current_search
......@@ -242,7 +243,7 @@ if Backbone?
goHome: ->
@template = _.template($("#discussion-home").html())
$(".discussion-column").html(@template)
$(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active")
$("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
......@@ -363,26 +364,24 @@ if Backbone?
@discussionIds = ""
@$('.forum-nav-filter-cohort').show()
@retrieveAllThreads()
else if item.hasClass("forum-nav-browse-menu-flagged")
@discussionIds = ""
@$('.forum-nav-filter-cohort').hide()
@retrieveFlaggedThreads()
else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed()
@$('.forum-nav-filter-cohort').hide()
else
allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id").id
(i, elem) -> $(elem).data("discussion-id")
).get()
@retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true)
chooseCohort: (event) ->
chooseFilter: (event) =>
@filter = $(".forum-nav-filter-main-control :selected").val()
@retrieveFirstPage()
chooseCohort: (event) =>
@group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
@retrieveFirstPage()
retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
......@@ -413,12 +412,6 @@ if Backbone?
@collection.reset()
@loadMorePages(event)
retrieveFlaggedThreads: (event)->
@collection.current_page = 0
@collection.reset()
@mode = 'flagged'
@loadMorePages(event)
sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
......@@ -434,6 +427,7 @@ if Backbone?
searchFor: (text) ->
@clearSearchAlerts()
@clearFilters()
@mode = 'search'
@current_search = text
url = DiscussionUtil.urlFor("search")
......@@ -499,6 +493,11 @@ if Backbone?
clearSearch: ->
@$(".forum-nav-search-input").val("")
@current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () =>
@mode = 'followed'
......
if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
"click .admin-pin":
(event) -> @togglePin(event)
"keydown .admin-pin":
(event) -> DiscussionUtil.activateOnSpace(event, @togglePin)
"click .action-follow": "toggleFollowing"
"keydown .action-follow":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFollowing)
"click .action-edit": "edit"
"click .action-delete": "_delete"
"click .action-openclose": "toggleClosed"
$: (selector) ->
@$el.find(selector)
initialize: ->
class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
super()
@model.on "change", @updateModelDetails
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
renderTemplate: ->
@template = _.template($("#thread-show-template").html())
@template(@model.toJSON())
context = $.extend(
{
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid
},
@model.attributes,
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderVote()
@renderFlagged()
@renderPinned()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
......@@ -44,60 +29,6 @@ if Backbone?
@highlight @$("h1,h3")
@
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
renderPinned: =>
pinElem = @$(".discussion-pin")
pinLabelElem = pinElem.find(".pin-label")
if @model.get("pinned")
pinElem.addClass("pinned")
pinElem.removeClass("notpinned")
if @model.can("can_openclose")
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
pinLabelElem.html(
interpolate(
gettext("Pinned%(start_sr_span)s, click to unpin%(end_span)s"),
{"start_sr_span": "<span class='sr'>", "end_span": "</span>"},
true
)
)
pinElem.attr("data-tooltip", gettext("Click to unpin"))
pinElem.attr("aria-pressed", "true")
else
pinLabelElem.html(gettext("Pinned"))
pinElem.removeAttr("data-tooltip")
pinElem.removeAttr("aria-pressed")
else
# If not pinned and not able to pin, pin is not shown
pinElem.removeClass("pinned")
pinElem.addClass("notpinned")
pinLabelElem.html(gettext("Pin Thread"))
pinElem.removeAttr("data-tooltip")
pinElem.attr("aria-pressed", "false")
updateModelDetails: =>
@renderVote()
@renderFlagged()
@renderPinned()
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
......@@ -109,74 +40,6 @@ if Backbone?
_delete: (event) ->
@trigger "thread:_delete", event
togglePin: (event) =>
event.preventDefault()
if @model.get('pinned')
@unPin()
else
@pin()
pin: =>
url = @model.urlFor("pinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', true)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble pinning this thread. Please try again.")
unPin: =>
url = @model.urlFor("unPinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', false)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble unpinning this thread. Please try again.")
toggleClosed: (event) ->
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
toggleEndorse: (event) ->
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
highlight: (el) ->
if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))
class @DiscussionThreadInlineShowView extends DiscussionThreadShowView
renderTemplate: ->
@template = DiscussionUtil.getTemplate('_inline_thread_show')
params = @model.toJSON()
if @model.get('username')?
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
Mustache.render(@template, params)
......@@ -7,14 +7,25 @@ if Backbone?
events:
"click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) ->
@$el.find(selector)
initialize: ->
isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@createShowView()
@responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
renderTemplate: ->
@template = _.template($("#thread-template").html())
......@@ -22,7 +33,6 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@initLocal()
@delegateEvents()
@renderShowView()
......@@ -31,11 +41,53 @@ if Backbone?
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@responses.on("add", @renderResponse)
@responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {}))
if @isQuestion()
@markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true}))
if @mode == "tab"
# Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error
setTimeout((=> @loadInitialResponses()), 100)
@
@$(".post-tools").hide()
else # mode == "inline"
@collapse()
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@renderAddResponseButton()
})
expand: (event) ->
if event
event.preventDefault()
@$el.addClass("expanded")
@$el.find(".post-body").html(@model.get("body"))
@showView.convertMath()
@$el.find(".forum-thread-expand").hide()
@$el.find(".forum-thread-collapse").show()
@$el.find(".post-extended-content").show()
if not @loadedResponses
@loadInitialResponses()
collapse: (event) ->
if event
event.preventDefault()
@$el.removeClass("expanded")
@$el.find(".post-body").html(@getAbbreviatedBody())
@showView.convertMath()
@$el.find(".forum-thread-expand").show()
@$el.find(".forum-thread-collapse").hide()
@$el.find(".post-extended-content").hide()
getAbbreviatedBody: ->
cached = @model.get("abbreviatedBody")
if cached
cached
else
abbreviated = DiscussionUtil.abbreviateString @model.get("body"), 140
@model.set("abbreviatedBody", abbreviated)
abbreviated
cleanup: ->
if @responsesRequest?
......@@ -54,9 +106,20 @@ if Backbone?
@responseRequest = null
success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info'])
@responses.add(data['content']['children'])
@renderResponseCountAndPagination(data['content']['resp_total'])
if @isQuestion()
@markedAnswers.add(data["content"]["endorsed_responses"])
@responses.add(
if @isQuestion()
then data["content"]["non_endorsed_responses"]
else data["content"]["children"]
)
@renderResponseCountAndPagination(
if @isQuestion()
then data["content"]["non_endorsed_resp_total"]
else data["content"]["resp_total"]
)
@trigger "thread:responses:rendered"
@loadedResponses = true
error: (xhr) =>
if xhr.status == 404
DiscussionUtil.discussionAlert(
......@@ -75,16 +138,24 @@ if Backbone?
)
loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true)
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) =>
@$el.find(".response-count").html(
interpolate(
ngettext(
if @isQuestion() && @markedAnswers.length != 0
responseCountFormat = ngettext(
"%(numResponses)s other response",
"%(numResponses)s other responses",
responseTotal
)
else
responseCountFormat = ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
),
)
@$el.find(".response-count").html(
interpolate(
responseCountFormat,
{numResponses: responseTotal},
true
)
......@@ -126,17 +197,17 @@ if Backbone?
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton)
renderResponse: (response) =>
renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model)
view = new ThreadResponseView(model: response)
view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
@$el.find(".responses").append(view.el)
@$el.find(listSelector).append(view.el)
view.afterInsert()
renderAddResponseButton: ->
if @model.hasResponses() and @model.can('can_reply')
renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show()
else
@$el.find('div.add-response').hide()
......@@ -150,9 +221,8 @@ if Backbone?
addComment: =>
@model.comment()
endorseThread: (endorsed) =>
is_endorsed = @$el.find(".is-endorsed").length
@model.set 'endorsed', is_endorsed
endorseThread: =>
@model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
submitComment: (event) ->
event.preventDefault()
......@@ -162,7 +232,7 @@ if Backbone?
@setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread'))
@renderResponse(comment)
@renderResponseToList(comment, ".js-response-list")
@model.addComment()
@renderAddResponseButton()
......@@ -209,6 +279,7 @@ if Backbone?
@model.set
title: newTitle
body: newBody
@model.unset("abbreviatedBody")
@createShowView()
@renderShowView()
......@@ -232,9 +303,6 @@ if Backbone?
renderEditView: () ->
@renderSubView(@editView)
getShowViewClass: () ->
return DiscussionThreadShowView
createShowView: () ->
if @editView?
......@@ -242,8 +310,7 @@ if Backbone?
@editView.$el.empty()
@editView = null
showViewClass = @getShowViewClass()
@showView = new showViewClass(model: @model)
@showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit
......
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
......@@ -10,38 +10,25 @@ if Backbone?
@topicId = options.topicId
render: () ->
context = _.clone(@course_settings.attributes)
_.extend(context, {
cohort_options: @getCohortOptions(),
mode: @mode,
form_id: @mode + (if @topicId then "-" + @topicId else "")
})
context.topics_html = @renderCategoryMap(@course_settings.get("category_map")) if @mode is "tab"
@$el.html(_.template($("#new-post-template").html(), context))
if @mode is "tab"
@$el.html(
_.template(
$("#new-post-tab-template").html(), {
topic_dropdown_html: @getTopicDropdownHTML(),
options_html: @getOptionsHTML(),
editor_html: @getEditorHTML()
}
)
)
# set up the topic dropdown in tab mode
@dropdownButton = @$(".topic_dropdown_button")
@topicMenu = @$(".topic_menu_wrapper")
@menuOpen = @dropdownButton.hasClass('dropped')
@topicId = @$(".topic").first().data("discussion_id")
@topicText = @getFullTopicName(@$(".topic").first())
$('.choose-cohort').hide() unless @$(".topic_menu li a").first().is("[cohorted=true]")
@setSelectedTopic()
else # inline
@$el.html(
_.template(
$("#new-post-inline-template").html(), {
options_html: @getOptionsHTML(),
editor_html: @getEditorHTML()
}
)
)
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
getTopicDropdownHTML: () ->
# populate the category menu (topic dropdown)
_renderCategoryMap = (map) ->
@dropdownButton = @$(".post-topic-button")
@topicMenu = @$(".topic-menu-wrapper")
@hideTopicDropdown()
@setTopic(@$("a.topic-title").first())
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "js-post-body"
renderCategoryMap: (map) ->
category_template = _.template($("#new-post-menu-category-template").html())
entry_template = _.template($("#new-post-menu-entry-template").html())
html = ""
......@@ -50,32 +37,24 @@ if Backbone?
entry = map.entries[name]
html += entry_template({text: name, id: entry.id, is_cohorted: entry.is_cohorted})
else # subcategory
html += category_template({text: name, entries: _renderCategoryMap(map.subcategories[name])})
html += category_template({text: name, entries: @renderCategoryMap(map.subcategories[name])})
html
topics_html = _renderCategoryMap(@course_settings.get("category_map"))
_.template($("#new-post-topic-dropdown-template").html(), {topics_html: topics_html})
getEditorHTML: () ->
_.template($("#new-post-editor-template").html(), {})
getOptionsHTML: () ->
# cohort options?
getCohortOptions: () ->
if @course_settings.get("is_cohorted") and DiscussionUtil.isStaff()
user_cohort_id = $("#discussion-container").data("user-cohort-id")
cohort_options = _.map @course_settings.get("cohorts"), (cohort) ->
_.map @course_settings.get("cohorts"), (cohort) ->
{value: cohort.id, text: cohort.name, selected: cohort.id==user_cohort_id}
else
cohort_options = null
context = _.clone(@course_settings.attributes)
context.cohort_options = cohort_options
_.template($("#new-post-options-template").html(), context)
null
events:
"submit .new-post-form": "createPost"
"click .topic_dropdown_button": "toggleTopicDropdown"
"click .topic_menu_wrapper": "setTopic"
"click .topic_menu_search": "ignoreClick"
"keyup .form-topic-drop-search-input": DiscussionFilter.filterDrop
"submit .forum-new-post-form": "createPost"
"click .post-topic-button": "toggleTopicDropdown"
"click .topic-menu-wrapper": "handleTopicEvent"
"click .topic-filter-label": "ignoreClick"
"keyup .topic-filter-input": DiscussionFilter.filterDrop
"change .post-option-input": "postOptionChange"
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to ignore clicks in the search field and stop propagation.
......@@ -83,15 +62,24 @@ if Backbone?
ignoreClick: (event) ->
event.stopPropagation()
postOptionChange: (event) ->
$target = $(event.target)
$optionElem = $target.closest(".post-option")
if $target.is(":checked")
$optionElem.addClass("is-enabled")
else
$optionElem.removeClass("is-enabled")
createPost: (event) ->
event.preventDefault()
title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val()
group = @$(".new-post-group option:selected").attr("value")
thread_type = @$(".post-type-input:checked").val()
title = @$(".js-post-title").val()
body = @$(".js-post-body").find(".wmd-input").val()
group = @$(".js-group-select option:selected").attr("value")
anonymous = false || @$("input.discussion-anonymous").is(":checked")
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
follow = false || @$("input.discussion-follow").is(":checked")
anonymous = false || @$(".js-anon").is(":checked")
anonymous_to_peers = false || @$(".js-anon-peers").is(":checked")
follow = false || @$(".js-follow").is(":checked")
url = DiscussionUtil.urlFor('create_thread', @topicId)
......@@ -103,24 +91,27 @@ if Backbone?
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
thread_type: thread_type
title: title
body: body
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
error: DiscussionUtil.formErrorHandler(@$(".post-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
DiscussionUtil.clearFormErrors(@$(".new-post-form-errors"))
DiscussionUtil.clearFormErrors(@$(".post-errors"))
@$el.hide()
@$(".new-post-title").val("").attr("prev-text", "")
@$(".new-post-body textarea").val("").attr("prev-text", "")
@$(".js-post-title").val("").attr("prev-text", "")
@$(".js-post-body textarea").val("").attr("prev-text", "")
@$(".wmd-preview p").html("") # only line not duplicated in new post inline view
@collection.add thread
toggleTopicDropdown: (event) ->
event.preventDefault()
event.stopPropagation()
if @menuOpen
@hideTopicDropdown()
......@@ -133,7 +124,6 @@ if Backbone?
@topicMenu.show()
$(".form-topic-drop-search-input").focus()
$("body").bind "keydown", @setActiveItem
$("body").bind "click", @hideTopicDropdown
# Set here because 1) the window might get resized and things could
......@@ -146,28 +136,33 @@ if Backbone?
@dropdownButton.removeClass('dropped')
@topicMenu.hide()
$("body").unbind "keydown", @setActiveItem
$("body").unbind "click", @hideTopicDropdown
setTopic: (event) ->
$target = $(event.target)
if $target.data('discussion_id')
handleTopicEvent: (event) ->
event.preventDefault()
event.stopPropagation()
@setTopic($(event.target))
setTopic: ($target) ->
if $target.data('discussion-id')
@topicText = $target.html()
@topicText = @getFullTopicName($target)
@topicId = $target.data('discussion_id')
@topicId = $target.data('discussion-id')
@setSelectedTopic()
if $target.is('[cohorted=true]')
$('.choose-cohort').show();
if $target.data("cohorted")
$(".js-group-select").prop("disabled", false)
else
$('.choose-cohort').hide();
$(".js-group-select").val("")
$(".js-group-select").prop("disabled", true)
@hideTopicDropdown()
setSelectedTopic: ->
@dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>')
@$(".js-selected-topic").html(@fitName(@topicText))
getFullTopicName: (topicElement) ->
name = topicElement.html()
topicElement.parents('ul').not('.topic_menu').each ->
name = $(this).siblings('a').text() + ' / ' + name
topicElement.parents('.topic-submenu').each ->
name = $(this).siblings('.topic-title').text() + ' / ' + name
return name
getNameWidth: (name) ->
......@@ -204,29 +199,3 @@ if Backbone?
name = gettext("…") + " / " + rawName + " " + gettext("…")
return name
setActiveItem: (event) ->
if event.which == 13
$(".topic_menu_wrapper .focused").click()
return
if event.which != 40 && event.which != 38
return
event.preventDefault()
items = $.makeArray($(".topic_menu_wrapper a").not(".hidden"))
index = items.indexOf($('.topic_menu_wrapper .focused')[0])
if event.which == 40
index = Math.min(index + 1, items.length - 1)
if event.which == 38
index = Math.max(index - 1, 0)
$(".topic_menu_wrapper .focused").removeClass("focused")
$(items[index]).addClass("focused")
itemTop = $(items[index]).parent().offset().top
scrollTop = $(".topic_menu").scrollTop()
itemFromTop = $(".topic_menu").offset().top - itemTop
scrollTarget = Math.min(scrollTop - itemFromTop, scrollTop)
scrollTarget = Math.max(scrollTop - itemFromTop - $(".topic_menu").height() + $(items[index]).height() + 20, scrollTarget)
$(".topic_menu").scrollTop(scrollTarget)
if Backbone?
class @ResponseCommentShowView extends DiscussionContentView
events:
"click .action-delete":
(event) -> @_delete(event)
"keydown .action-delete":
(event) -> DiscussionUtil.activateOnSpace(event, @_delete)
"click .action-edit":
(event) -> @edit(event)
"keydown .action-edit":
(event) -> DiscussionUtil.activateOnSpace(event, @edit)
class @ResponseCommentShowView extends DiscussionContentShowView
tagName: "li"
initialize: ->
super()
@model.on "change", @updateModelDetails
abilityRenderer:
can_delete:
enable: -> @$(".action-delete").show()
disable: -> @$(".action-delete").hide()
editable:
enable: -> @$(".action-edit").show()
disable: -> @$(".action-edit").hide()
render: ->
@template = _.template($("#response-comment-show-template").html())
params = @model.toJSON()
@$el.html(
@template(
_.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay()
},
@model.attributes
)
)
)
@$el.html(@template(params))
@initLocal()
@delegateEvents()
@renderAttrs()
@renderFlagged()
@markAsStaff()
@$el.find(".timeago").timeago()
@convertMath()
@addReplyLink()
......@@ -52,31 +35,8 @@ if Backbone?
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="staff-label">' + gettext('staff') + '</span>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="community-ta-label">' + gettext('Community TA') + '</span>')
_delete: (event) =>
@trigger "comment:_delete", event
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Misuse Reported, click to remove report"))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Report Misuse"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderFlagged()
edit: (event) =>
@trigger "comment:edit", event
if Backbone?
class @ThreadResponseShowView extends DiscussionContentView
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .action-endorse": "toggleEndorse"
"click .action-delete": "_delete"
"click .action-edit": "edit"
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
$: (selector) ->
@$el.find(selector)
class @ThreadResponseShowView extends DiscussionContentShowView
initialize: ->
super()
@model.on "change", @updateModelDetails
@listenTo(@model, "change", @render)
renderTemplate: ->
@template = _.template($("#thread-response-show-template").html())
@template(@model.toJSON())
context = _.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay()
},
@model.attributes
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderVote()
@renderAttrs()
@renderFlagged()
@$el.find(".posted-details").timeago()
@$el.find(".posted-details .timeago").timeago()
@convertMath()
@markAsStaff()
@
convertMath: ->
......@@ -39,54 +29,8 @@ if Backbone?
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.addClass("staff")
@$el.prepend('<div class="staff-banner">' + gettext('staff') + '</div>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.addClass("community-ta")
@$el.prepend('<div class="community-ta-banner">' + gettext('Community TA') + '</div>')
edit: (event) ->
@trigger "response:edit", event
_delete: (event) ->
@trigger "response:_delete", event
toggleEndorse: (event) ->
event.preventDefault()
if not @model.can('can_endorse')
return
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
@model.set('endorsed', not endorsed)
@trigger "comment:endorse", not endorsed
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderVote()
@renderFlagged()
if Backbone?
class @ThreadResponseView extends DiscussionContentView
tagName: "li"
className: "forum-response"
events:
"click .discussion-submit-comment": "submitComment"
......@@ -9,7 +10,8 @@ if Backbone?
$: (selector) ->
@$el.find(selector)
initialize: ->
initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView()
renderTemplate: ->
......@@ -65,6 +67,15 @@ if Backbone?
collectComments(child)
@model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null)
if @collapseComments && comments.length
@$(".comments").hide()
@$(".action-show-comments").on("click", (event) =>
event.preventDefault()
@$(".action-show-comments").hide()
@$(".comments").show()
)
else
@$(".action-show-comments").hide()
renderComment: (comment) =>
comment.set('thread', @model.get('thread'))
......@@ -155,6 +166,7 @@ if Backbone?
@showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () ->
@renderSubView(@showView)
......
......@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory):
class Thread(ContentFactory):
thread_type = "discussion"
anonymous = False
anonymous_to_peers = False
comments_count = 0
......@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture):
def addResponse(self, response, comments=[]):
response['children'] = comments
self.thread.setdefault('children', []).append(response)
if self.thread["thread_type"] == "discussion":
responseListAttr = "children"
elif response["endorsed"]:
responseListAttr = "endorsed_responses"
else:
responseListAttr = "non_endorsed_responses"
self.thread.setdefault(responseListAttr, []).append(response)
self.thread['comments_count'] += len(comments) + 1
def _get_comment_map(self):
......
from contextlib import contextmanager
from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise
......@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
query = self._find_within(selector)
return query.present and query.visible
@contextmanager
def _secondary_action_menu_open(self, ancestor_selector):
"""
Given the selector for an ancestor of a secondary menu, return a context
manager that will open and close the menu
"""
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu opened"
).fulfill()
yield
if self._is_element_visible(ancestor_selector + " .actions-dropdown"):
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: not self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu closed"
).fulfill()
def get_response_total_text(self):
"""Returns the response count text, or None if not present"""
return self._get_element_text(".response-count")
......@@ -89,12 +110,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_response_edit(self, response_id):
"""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()
EmptyPromise(
lambda: self.is_response_editor_visible(response_id),
"Response edit started"
).fulfill()
def is_show_comments_visible(self, response_id):
"""Returns true if the "show comments" link is visible for a response"""
return self._is_element_visible(".response_{} .action-show-comments".format(response_id))
def show_comments(self, response_id):
"""Click the "show comments" link for a response"""
self._find_within(".response_{} .action-show-comments".format(response_id)).first.click()
EmptyPromise(
lambda: self._is_element_visible(".response_{} .comments".format(response_id)),
"Comments shown"
).fulfill()
def is_add_comment_visible(self, response_id):
"""Returns true if the "add comment" form is visible for a response"""
return self._is_element_visible("#wmd-input-comment-body-{}".format(response_id))
......@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_deletable(self, comment_id):
"""Returns true if the delete comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} div.action-delete".format(comment_id))
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-delete".format(comment_id))
def delete_comment(self, comment_id):
with self.handle_alert():
self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click()
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-delete".format(comment_id)).first.click()
EmptyPromise(
lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed"
......@@ -120,6 +156,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_editable(self, comment_id):
"""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))
def is_comment_editor_visible(self, comment_id):
......@@ -132,6 +169,7 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_comment_edit(self, comment_id):
"""Click the edit button for the comment, loading the editing view"""
old_body = self.get_comment_body(comment_id)
with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
EmptyPromise(
lambda: (
......@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage):
def expand(self):
"""Clicks the link to expand the thread"""
self._find_within(".expand-post").first.click()
self._find_within(".forum-thread-expand").first.click()
EmptyPromise(
lambda: bool(self.get_response_total_text()),
"Thread expanded"
......
......@@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati
self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201
self.thread_page.visit()
def test_marked_answer_comments(self):
thread_id = "test_thread_{}".format(uuid4().hex)
response_id = "test_response_{}".format(uuid4().hex)
comment_id = "test_comment_{}".format(uuid4().hex)
thread_fixture = SingleThreadViewFixture(
Thread(id=thread_id, commentable_id=self.discussion_id, thread_type="question")
)
thread_fixture.addResponse(
Response(id=response_id, endorsed=True),
[Comment(id=comment_id)]
)
thread_fixture.push()
self.setup_thread_page(thread_id)
self.assertFalse(self.thread_page.is_comment_visible(comment_id))
self.assertFalse(self.thread_page.is_add_comment_visible(response_id))
self.assertTrue(self.thread_page.is_show_comments_visible(response_id))
self.thread_page.show_comments(response_id)
self.assertTrue(self.thread_page.is_comment_visible(comment_id))
self.assertTrue(self.thread_page.is_add_comment_visible(response_id))
self.assertFalse(self.thread_page.is_show_comments_visible(response_id))
@attr('shard_1')
class DiscussionCommentDeletionTest(UniqueCourseTest):
......
......@@ -2,6 +2,22 @@
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
**************
......
......@@ -51,7 +51,7 @@ Create a Discussion Component
course content. The values in the **Category** and **Subcategory** fields
appear in the list of discussion topics on the **Discussion** page. To
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
:alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded"
......
......@@ -161,7 +161,13 @@ D
**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.
......@@ -433,6 +439,29 @@ P
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:
****
......
.. _Discussions:
##################################
Managing the Course Discussions
Managing Course Discussions
##################################
Discussions, or discussion forums, foster interaction among your students and
......@@ -31,13 +31,13 @@ sections:
Overview
********************************
Students and staff use course discussions to share ideas, exchange views, and
consider different viewpoints. In a discussion, there are three hierarchical
levels of interaction.
Students and staff use course discussions to share ideas, exchange views,
consider different viewpoints, and ask questions. In a discussion, there are
three hierarchical levels of interaction.
* A *post* is the first level of interaction. A post opens a new subject. Posts
are often posed as questions, either to start a conversation or to surface an
issue that requires some action.
issue that requires some action. When you add a post, you categorize it as a **Question** or as a **Discussion**.
* A *response* is the second level of interaction. A response is a reply made
directly to a post to provide a solution or continue the conversation.
......@@ -46,13 +46,15 @@ levels of interaction.
clarification or side note made to a specific response, rather than to the
post as a whole.
The dialog created by a post, its responses, and the comments on those
responses is called a *thread*.
The dialogue created by a post, its responses, and the comments on those
responses is sometimes called a thread.
All course staff members and enrolled students can add posts, responses, and
comments, and view all of the posts, responses, and comments made by other
course participants. Discussion threads are saved as part of the course
history.
course participants. Members of the course community, both staff and students,
can be given permission to moderate or administer course discussions through a
set of discussion administration roles. Discussion threads are saved as part of
the course history.
.. note::
The :ref:`Discussions for Students and Staff` chapter describes features that
......@@ -64,7 +66,7 @@ history.
.. _Organizing_discussions:
*************************************************
Set Up Discussions for Your Course
Set Up Discussion Topics for Your Course
*************************************************
Discussions in an edX course include both the specific topics that you add to
......@@ -77,11 +79,12 @@ Add Units With a Discussion Component
============================================
Typically, all units are added during the design and creation of your course in
Studio. To add a component to a unit, follow the instructions in :ref:`Working
with Discussion Components`.
Studio. To add a discussion topic to a unit, you add a discussion component.
Follow the instructions in :ref:`Working with Discussion Components`.
This type of discussion is subject to the release date of the section that
contains it. Students cannot contribute to these discussions until that date.
This type of discussion topic is subject to the release date of the section
that contains it. Students cannot contribute to these discussion topics until
that date.
=====================================
Create Course-Wide Discussion Topics
......@@ -150,24 +153,27 @@ You can designate a team of people to help you run course discussions.
Different options for working with discussions are available through
these roles:
* Discussion moderators can edit and delete messages at any level, review
* *Discussion moderators* can edit and delete messages at any level, review
messages flagged for misuse, close and reopen posts, pin posts, and endorse
responses. Posts made by moderators are marked as "By: Staff" in the list of
posts. Responses and comments made by moderators have a colored "Staff"
banner. This role is often given to course team members who already have the
Course Staff role.
identifier. This role is often given to course team members who already have
the Course Staff role.
.. removed this clause from 1st sentence per JAAkana and MHoeber: , and, if the
.. course is cohorted, see posts from all cohorts
* Discussion community TAs have the same options for working with discussions
* *Discussion community TAs* have the same options for working with discussions
as moderators. Posts made by community TAs are marked as "By: Community TA"
in the list of posts. Responses and comments made by community TAs have a
colored "Community TA" banner. This role is often given to students.
in the list of posts on the **Discussion** page. Responses and comments made
by community TAs have a colored "Community TA" identifier. This role is often
given to students.
* Discussion admins have the same options for working with discussions as
.. put this comment in to make the formatting of this bulleted list consistent when output using the spinx template
* *Discussion admins* have the same options for working with discussions as
moderators, and their posts, responses, and comments have the same "Staff"
identifier. This role can be reserved for assignment to course team members
identifiers. This role can be reserved for assignment to course team members
who have the Instructor role only: the discussion admins can then both
moderate discussions and give other users these discussion management roles
whenever necessary.
......@@ -179,7 +185,7 @@ addresses or usernames.
click **Membership** and then select **Course Staff** or **Instructor** from
the drop-down list.
* To get this information for any enrolled student, on the Instructor Dashboard
* To get this information for an enrolled student, on the Instructor Dashboard
click **Data Download**, then **Download profile information as a CSV**.
To assign a role, you must be the course author or an Instructor (that is, you
......@@ -206,9 +212,9 @@ Run a Discussion
*********************
On an ongoing basis, the members of your discussion team run the course
discussion by making contributions, endorsing responses, and guiding student
messages into pertinent threads. Techniques that you can use throughout your
course to make discussions successful follow.
discussion by making contributions, endorsing responses, marking answers as
correct, and guiding student messages into pertinent threads. Techniques that
you can use throughout your course to make discussions successful follow.
==========================================
Use Conventions in Discussion Subjects
......@@ -229,13 +235,21 @@ body of a response or comment. Examples follow.
Both your discussion team and your students can use tags like these to search
the discussions more effectively.
When a post is created its type must be selected: either "question" or
"discussion". Members of the discussion team should be thoughtful when
selecting the type for their posts, and encourage students to do the same. See
:ref:`Find Question Posts and Discussion Posts`.
.. future: changing the type of a post, maybe resequence or separate conventions from post types
========================
Seed Discussions
Seed Discussion Topics
========================
To help students learn how to get the most of course discussions, and find the
best discussion topic to use for their questions, you can seed discussions by
adding posts before your course starts. Some examples follow.
To help students learn how to get the most out of course discussions, and find
the best discussion topic to use for their questions and conversations, you can
seed discussion topics by adding posts before your course starts. Some examples
follow.
* In the General topic (which is included in every course by default), add an
[INTRO] post to initiate a thread for student and staff introductions.
......@@ -246,8 +260,8 @@ adding posts before your course starts. Some examples follow.
create their own posts.
* If you include discussion components along with problem components in a unit,
you can add a post that encourages students to use the discussion topic to
ask for help with the problems, but reminds them not to post the answers.
you can add a post that encourages students to use the topic to ask for help
with the problems, but reminds them not to post the answers.
======================================
Minimize Thread Proliferation
......@@ -259,30 +273,35 @@ long threads (with more than 200 responses and comments) can be difficult to
read, and can therefore result in an unsatisfactory experience in the
discussion.
* Pin a post. Pinning a post makes it appear at the top of the list of posts.
As a result, it is more likely that students will see and respond to pinned
posts. You can write your own post and then pin it, or pin a post by any
author. Click **Pin Thread**.
* Pin a post. Pinning a post makes it appear at the top of the list of posts on
the **Discussion** page. As a result, it is more likely that students will
see and respond to pinned posts. You can write your own post and then pin it,
or pin a post by any author. Select the "More" icon and then **Pin**.
.. image:: ../Images/Pin_Discussion.png
:alt: Image of the pin icon for discussion posts
* Endorse a response. Endorsing a response indicates that it provides value to
the discussion, such as a correct answer to a question. Click the **check
mark** that displays at upper right of the response.
the discussion. Click the "check mark" (or tick mark) icon for the response.
.. image:: ../Images/Endorse_Discussion.png
:alt: Image of the Endorse button for discussion posts
* Mark a question as answered. You use the same procedure to mark a response as
the correct answer to a question as you do to endorse contributions to a
discussion: click the "check mark" (or tick mark) icon for correct answers.
* Close a post. You can respond to a redundant post by (optionally) pasting in
a link to the post that you prefer students to contribute to, and prevent
further interaction by closing the post. Click the **Close** button that
displays below the post to close it.
further interaction by closing the post. Select the "More" icon and then
**Close** to close it.
* Provide post/response/comment guidelines. A set of :ref:`Guidance for
Discussion Moderators` or a post in a course-wide discussion topic (such
as **General**) can provide guidance about when to start a new thread by
adding a post, responding to an existing post, or commenting on a response.
* Provide post/response/comment guidelines. You can post information from the
:ref:`overview<Overview_discussions>` in this chapter, or the :ref:`anatomy
of edX discussions<Anatomy of edX Course Discussions>` in the next chapter,
in a course-wide discussion topic (such as General) to provide guidance about
when to start a new thread by adding a post, responding to an existing post,
or commenting on a response.
.. _Moderating_discussions:
......@@ -313,6 +332,11 @@ them available to students as a course handout file or on a defined page in
your course. These guidelines can define your expectations and optionally
introduce features of edX discussions.
You can also share the :ref:`Discussions for Students and Staff` chapter with
your students. It describes features that are available to all discussion
participants, and may be useful to students who are new to online discussion
forums.
.. For a template that you can use to develop your own guidelines, see
.. :ref:`Discussion Forum Guidelines`.
......@@ -320,8 +344,8 @@ introduce features of edX discussions.
Develop a Positive Discussion Culture
========================================
Monitors can cultivate qualities in their own discussion interactions to make
their influence positive and their time productive.
Discussion monitors can cultivate qualities in their own discussion
interactions to make their influence positive and their time productive.
* Encourage quality contributions: thank students whose posts have a positive
impact and who answer questions.
......@@ -352,6 +376,32 @@ their influence positive and their time productive.
For a template that you can use to develop guidelines for your course
moderators, see :ref:`Guidance for Discussion Moderators`.
.. _Find Question Posts and Discussion Posts:
==========================================
Find Questions and Discussions
==========================================
When students create posts, they specify the type of post to indicate whether
they are asking for concrete information (a question) or starting an open-ended
conversation (a discussion).
On the **Discussion** page, a question mark image identifies posts that ask
questions, and a conversation bubble image identifies posts that start
discussions. When an answer is provided and marked as correct for a question, a
check or tick mark image replaces the question mark image. See :ref:`Answer
Questions`.
In addition to these visual cues, filters can help you find questions and
discussions that need review. Above the list of posts on the **Discussion**
page, the **Show all** filter is selected by default. You can also select:
* **Unread**, to list only the discussions and questions that you have not yet
viewed.
* **Unanswered**, to list only questions that do not yet have any responses
marked as answers.
==================
Edit Messages
==================
......@@ -364,8 +414,12 @@ text, images, or links.
#. Log in to the site and then select the course on your **Current Courses**
dashboard.
#. Click the **Edit** button below the post or response or the pencil icon for
the comment.
#. Open the **Discussion** page and then open the post with the content that
requires editing. You can select a single topic from the drop-down list of
discussion topics, apply a filter, or search to locate the post.
#. For the post or for the response or comment that you want to edit, click the
"More" icon and then **Edit**.
#. Remove the problematic portion of the message, or replace it with standard
text such as "[REMOVED BY MODERATOR]".
......@@ -384,33 +438,41 @@ language may need to be deleted, rather than edited.
#. Log in to the site and then select the course on your **Current Courses**
dashboard.
#. Click the **Delete** button below the post or response or the "X" icon for
the comment.
#. Open the **Discussion** page and then open the post with the content that
requires deletion. You can select a single topic from the drop-down list of
discussion topics, apply a filter, or search to locate the post.
#. For the post or for the response or comment that you want to delete, click
the "More" icon and then **Delete**.
#. Click **OK** to confirm the deletion.
.. how to communicate with the poster?
.. important:: If a message is threatening or indicates serious harmful intent, contact campus security at your institution. Report the incident before taking any other action.
.. important:: If a message is threatening or indicates serious harmful
intent, contact campus security at your institution. Report the incident
before taking any other action.
==================================
Respond to Reports of Misuse
==================================
Students can use the **Report Misuse** flag to indicate messages that they find
inappropriate. Moderators, community TAs, and admins can check for messages
that have been flagged in this way and edit or delete them as needed.
Students have the option to report contributions that they find inappropriate.
Moderators, community TAs, and admins can check for messages that have been
flagged in this way and edit or delete them as needed.
#. View the live version of your course and click **Discussion** at the top of
the page.
#. On the drop-down Discussion list click **Flagged Discussions**.
#. In the list of posts on the left side of the page, use the filter drop-down
list (set to **Show all** by default) to select **Flagged**.
#. Review each post listed as a flagged discussion. Posts and responses show a
flag and **Misuse Reported** in red font; comments show only a red flag.
#. Review listed posts. A post is listed if it or any of its responses or
comments has been reported. The reported contribution includes a
**Reported** identifier.
#. Edit or delete the post, response, or comment. Alternatively, to remove the
misuse flag from a message click **Misuse Reported** or the red flag icon.
#. Edit or delete the post, response, or comment. Alternatively, remove the
flag: click the "More" icon and then **Unreport**.
===============
Block Users
......@@ -443,7 +505,9 @@ course units and all of the course-wide topics are affected.
and Discussion Community TAs are not affected when you close the discussions
for a course. Users with these roles can continue to add to discussions.
.. note:: To assure that your students understand why they cannot add to discussions, you can add the dates that discussions are closed to the **Course Info** page and post them to a General discussion.
.. note:: To assure that your students understand why they cannot add to
discussions, you can add the dates that discussions are closed to the
**Course Info** page and post them to a General discussion.
=====================================
Start-End Date Format Specification
......@@ -518,4 +582,6 @@ reopen:
:alt: Same policy value but with a line feed after each bracket and comma,
and an indent before each date
.. For examples of email messages that you can send to let students know when the course discussions are closed (or open), see :ref:`Example Messages to Students`.
For examples of email messages that you can send to let students know when the
course discussions are closed (or open), see :ref:`Example Messages to
Students`.
\ No newline at end of file
......@@ -19,11 +19,7 @@ participation more effective. These include ways to:
* :ref:`Keep Up with New Activity`
* :ref:`Follow Posts`
* :ref:`Vote for Posts or Responses`
* :ref:`Report Discussion Misuse`
* :ref:`React to Contributions`
.. _Anatomy of edX Course Discussions:
......@@ -60,19 +56,23 @@ Discussion Topics
====================================
Most edX courses include opportunities to discuss specific video lectures,
reading assignments, questions, or other course content. Each of these content-
specific discussion opportunities is called a *topic*. When these discussion
topics are included in a course, they typically appear below the content they apply to.
reading assignments, homework problems, or other course content. Each of these
content-specific discussion opportunities is called a *topic*. When these
discussion topics are included in a course, they typically appear below the
content they apply to.
.. image:: /Images/Discussion_content_specific.png
:alt: A discussion topic that appears below a video in the course, identified by a "Show Discussion" link
:alt: A discussion topic that appears below a video in the course, identified
by a "Show Discussion" link
Most courses also include one or more topics for discussions about course-wide
areas of interest, such as "Frequently Asked Questions" or "Troubleshooting".
You access these topics on the **Discussion** page of the course.
Most courses also include one or more topics for course-wide discussions, such
as "Frequently Asked Questions" and "Troubleshooting". You access these topics
on the **Discussion** page of the course: click the **All Discussions**
drop-down.
.. image:: /Images/Discussion_course_wide.png
:alt: Discussion topics are listed on the Discussion page when you click the drop-down list at the left side of the page
:alt: Discussion topics are listed on the Discussion page when you click the
drop-down list at the left side of the page
When you visit the **Discussion** page, you can read and add to any of the
discussion topics.
......@@ -81,14 +81,39 @@ discussion topics.
* Content-specific topics are indented under an identifying category name.
Notice that while you can access content-specific topics both on the
**Discussion** page and while you are navigating through course content on the
**Courseware** page, you can only access the course-wide topics on the
Notice that you can access content-specific topics both on the **Discussion**
page and also while you are navigating through course content on the
**Courseware** page. However, you can only access the course-wide topics on the
**Discussion** page.
Before you add a post, look through the topics. When add your post to the most
appropriate topic, others with the same interest can find, read, and respond to
it more easily.
Before you add a post, look through the topics. When you add your post to the
most appropriate topic, others with the same interest can find, read, and
respond to it more easily.
====================================
Types of Discussion Posts
====================================
When you make a contribution to a course discussion topic, it can typically be
categorized as either a question or a discussion.
* A *question* post raises an issue so that the course staff and community can
provide answers.
* A *discussion* post starts a conversation by sharing thoughts and
reflections, and inviting community participation.
When you add a post to a discussion topic, you specify whether it is a question
or a discussion. When you visit the **Discussion** page for your course, a
question mark image identifies posts that ask questions and a conversation
bubble image identifies posts that start discussions.
.. image:: ../Images/Post_types_in_list.png
:alt: The list of posts with images identifying questions and discussions
If you have any difficulty deciding which type of post you want to add, think
about whether you want to get concrete information (a question) or start an
open-ended conversation (a discussion).
.. _Find Posts:
......@@ -96,11 +121,12 @@ it more easily.
Find Posts
******************************
Finding out whether someone else has already started a conversation about the
same subject that interests you, and then reading and contributing to that
exchange instead of starting a new one, helps make the time that everyone
spends with the course discussion more productive. You can search for something
specific, or you can browse through the posts in a single discussion topic.
Finding out whether someone else has already asked the same question or
initiated a conversation about the same subject that interests you, and then
reading and contributing to that exchange instead of starting a new one, helps
make the time that everyone spends with the course discussion more productive.
You can search for something specific, or you can browse through the posts in a
single discussion topic.
=======================
Search the Discussions
......@@ -120,8 +146,8 @@ press Enter, the search tries to find:
level.
* Any usernames that are an exact match to your text. A "Show posts by
{username}" option displays above any posts that have an exact match at any
interaction level. Click the username in the message to read that user's
{username}" option displays above any posts that have an exact text match at
any interaction level. Click the username in the message to read that user's
posts, responses, and comments.
==============================================
......@@ -133,7 +159,22 @@ To review posts about a particular part of the course or type of issue, click
down list. (**All Discussions** is selected by default.) Only posts about the
topic you select appear in the list of posts.
.. add something about endorsed responses(?)
.. image:: ../Images/Discussion_filters.png
:alt: The list of posts with callouts to identify the top filter to select
one topic and the filter below it to select by state
=======================================
Review Only Unread or Unanswered Posts
=======================================
To limit the posts shown on the **Discussion** page, you can select one of the filter options. Above the list of posts, the **Show all** filter
is selected by default.
* To list only the discussions and questions that you have not yet viewed,
select **Unread**.
* To list only question posts that do not yet have any responses marked as
answers, select **Unanswered**.
.. _Add a Post:
......@@ -141,14 +182,13 @@ topic you select appear in the list of posts.
Add a Post, Response, or Comment
************************************
.. this section is likely to be more interesting and valuable when we add the discussion vs. question differentiation
================================
Add a Post
================================
To make sure that other students and the course team can find and respond to
your posts, try to add your posts to the most appropriate topic.
your post, try to select the correct type for your post: either question or
discussion.
Add a Post to a Content-Specific Discussion Topic
**************************************************
......@@ -162,7 +202,7 @@ Add a Post to a Content-Specific Discussion Topic
**Show Discussion**.
You can scroll through the posts that have already been added: the title and
the first sentence or two of each post appear. To read the entire post, view
the first sentence or two of each post appear. To read an entire post, view
the responses to it, and see any comments, click **Expand discussion**.
4. To add a post, click **New Post**.
......@@ -170,7 +210,9 @@ Add a Post to a Content-Specific Discussion Topic
.. image:: /Images/Discussion_content_specific_post.png
:alt: Adding a post about specific course content
5. Enter a short, descriptive identifier for your post in the **Title** field.
5. Select the type of post: click **Question** or **Discussion**.
#. Enter a short, descriptive identifier for your post in the **Title** field.
The title is the part of your post that others see when they are browsing on
the **Discussion** page or scrolling through one of the content-specific
topics.
......@@ -188,12 +230,14 @@ discussion topics.
#. Click **New Post**.
#. Select the type of post: click **Question** or **Discussion**.
#. Select the most appropriate discussion topic for your post.
.. image:: /Images/Discussion_course_wide_post.png
.. image:: /Images/Discussion_course_wide_post.png
:alt: Selecting the topic for a new post on the Discussion page
4. Supply a short, descriptive **Title**. The title is the part of your post
5. Supply a short, descriptive **Title**. The title is the part of your post
that others see when they are browsing on the **Discussion** page or
scrolling through one of the content-specific topics.
......@@ -224,12 +268,15 @@ Add a Response or Comment to a Content-Specific Discussion Topic
#. Click **Expand discussion**.
#. Add a response or comment.
.. image:: /Images/Discussion_expand.png
:alt: The **Expand discussion** link under a post
To add a response to the post, click **Add A Response** below the post. When
your response is complete, click **Submit**.
6. Add a response or comment.
To add a comment to a response, click in the **Add a comment** field below
- To add a response to the post, click **Add A Response**. When your response
is complete, click **Submit**.
- To add a comment to a response, click in the **Add a comment** field below
the response. When your comment is complete, click **Submit**.
Add a Response or Comment to a Course-Wide Discussion Topic
......@@ -245,13 +292,15 @@ content-specific discussion topics.
#. Add a response or comment.
To add a response to the post, click **Add A Response** below the post. When
your response is complete, click **Submit**.
- To add a response to the post, click **Add A Response**. When your response
is complete, click **Submit**.
To add a comment to a response, click in the **Add a comment** field below
the response. When your comment is complete, click **Submit**.
.. image:: /Images/Discussion_add_response.png
:alt: The **Add A Response** button located between a post and its
responses
.. images to come
- To add a comment to a response, click in the **Add a comment** field below
the response. When your comment is complete, click **Submit**.
.. _Keep Up with New Activity:
......@@ -268,21 +317,30 @@ identify posts that are new, or that have responses or comments that you have
not read yet, and to distinguish them from exchanges that you have already read
completely.
* Posts that you have not read yet have a blue dialog "bubble".
* Posts that you have not read yet have a blue callout image.
* Posts with responses or comments that you have not read yet have a white
dialog "bubble".
* Posts that you have read, but with responses or comments that you have not
read yet, have a white callout image.
* Exchanges that you have read completely have a gray dialog "bubble" and
* Exchanges that you have read completely have a gray callout image and
background.
.. image:: ../Images/Discussion_colorcoding.png
:alt: The list of posts with posts showing differently colored backgrounds and bubble icons
:alt: The list of posts with posts showing differently colored backgrounds
and callout images
The total number of contributions in the exchange (the post and its responses
and comments) appears in each callout image. To see the number of contributions
that you haven't read yet, move your cursor over the callout image.
.. image:: ../Images/Discussion_mouseover.png
:alt: A post with 4 contributions total and a popup that shows only two are
unread
These color-coded dialog bubbles appear when you sort the list of posts by
recent activity or by most activity. If you sort the list of posts by most
votes instead, the number of votes that the post has received appears in place
of the bubble icon. See :ref:`Vote for Posts or Responses`.
The color-coded callout images appear when you sort the list of posts **by
recent activity** or **by most activity**. If you sort the list of posts by
most votes instead, the number of votes that the post has received appears in
place of the callouts. See :ref:`Vote for Posts or Responses`.
==============================
Receive Daily Digests
......@@ -292,37 +350,52 @@ You have the option to receive an email message each day that summarizes
discussion activity for the posts you are following. To receive this daily
digest, click **Discussion** and then select the **Receive updates** checkbox.
.. _Follow Posts:
.. _React to Contributions:
************************************
Follow Posts
Provide Feedback on Contributions
************************************
If you find a post particularly interesting and want to return to it in the
future, you can follow it: view that post and click the star icon in its top
right corner.
As you read the contributions that other students and staff make to discussion
topics, you can provide feedback without writing a complete response or
comment. You can:
.. image:: ../Images/Discussion_follow.png
:alt: A post with the Follow icon circled
* :ref:`Vote for posts and responses<Vote for Posts or Responses>` to provide
positive feedback.
Each post that you follow appears with a "Following" badge in the list of
posts.
* :ref:`Follow posts<Follow Posts>` so that you can check back in on
interesting conversations and questions easily.
To list only the posts that you are following, regardless of the discussion
topic they apply to, click the drop-down Discussion list and select
**Posts I'm Following**.
* :ref:`Answer questions, and mark your questions as answered<Answer
Questions>`.
.. image:: ../Images/Discussion_filterfollowing.png
:alt: The list of posts with the "Posts I'm Following" filter selected. Every post shows the following badge.
* :ref:`Report a contribution<Report Discussion Misuse>` that is inappropriate
to the course staff.
To select a feedback option, you use the icons at the top right of each post,
response, or comment. When you move your cursor over these icons a label
appears.
.. image:: ../Images/Discussion_options_mouseover.png
:alt: The icons at top right of a post, shown before the cursor is
placed over each one and with the Vote, Follow, and More labels
When you click the "More" icon, a menu of the options that currently apply
appears.
.. image:: ../Images/Discussion_More_menu.png
:alt: The More icon expanded to show a menu with one option and a menu with
three options
.. _Vote for Posts or Responses:
************************************
==============================
Vote for Posts or Responses
************************************
==============================
If you like a post or one of its responses, you can vote for it: view the
post or response and click the **+** at top right.
post or response and click the "Vote" icon at top right.
.. image:: ../Images/Discussion_vote.png
:alt: A post with the Vote icon circled
......@@ -332,22 +405,70 @@ the top: click the drop-down list of sorting options and select **by most
votes**.
.. image:: ../Images/Discussion_sortvotes.png
:alt: The list of posts with the "by most votes" sorting option and the number of votes for the post circled
:alt: The list of posts with the "by most votes" sorting option and the
number of votes for the post circled
The number of votes that each post has received displays in the list of posts.
(Votes for responses are not included in the number.)
.. _Follow Posts:
==============================
Follow Posts
==============================
If you find a post particularly interesting and want to return to it in the
future, you can follow it: view that post and click the "Follow" icon.
.. image:: ../Images/Discussion_follow.png
:alt: A post with the Follow icon circled
Each post that you follow appears with a "Following" indicator in the list of
posts.
To list only the posts that you are following, regardless of the discussion
topic they apply to, click the drop-down Discussion list and select
**Posts I'm Following**.
.. image:: ../Images/Discussion_filterfollowing.png
:alt: The list of posts with the "Posts I'm Following" filter selected. Every
post in the list shows the following indicator.
.. _Answer Questions:
============================================================
Answer Questions and Mark Questions as Answered
============================================================
Anyone in a course can answer questions. Just add a response to the question
post with your answer.
The person who posted the question (and staff members) can mark responses as
correct: click the "Mark as Answer" icon that appears at upper right of
the response.
.. image:: ../Images/Discussion_answer_question.png
:alt: A question and a response, with the Mark as Answer icon circled
After at least one response is marked as the answer, a check or tick mark image
replaces the question mark image for the post in the list on the **Discussion**
page.
.. image:: ../Images/Discussion_answers_in_list.png
:alt: The list of posts with images identifying unanswered and answered
questions and discussions
.. _Report Discussion Misuse:
************************************
==============================
Report Discussion Misuse
************************************
==============================
You can flag any post, response, or comment for a discussion moderator to
review: view the post or response and then click **Report Misuse**. For a
comment, click the flag.
review: view the contribution, click the "More" icon, and then click
**Report**.
.. image:: ../Images/Discussion_reportmisuse.png
:alt: A post and a response with the "Report Misuse" link circled, and a comment with the flag icon circled
:alt: A post and a response with the "Report" link circled
.. Future: DOC-121 As a course author, I need a template of discussion guidelines to give to students
\ No newline at end of file
......@@ -11,6 +11,10 @@ Change Log
* - Date
- 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
- Removed information on course grading. See `Establishing a Grading
Policy <http://edx.readthedocs.org/projects/edx-partner-course-
......
......@@ -4,21 +4,34 @@
Discussion Forums Data
######################
EdX discussion data is stored as collections of JSON documents in a MongoDB database. MongoDB is a document-oriented, NoSQL database system. Documentation can be found at the mongodb_ web site.
EdX discussion data is stored as collections of JSON documents in a MongoDB
database. MongoDB is a document-oriented, NoSQL database system. Documentation
can be found at the mongodb_ web site.
.. _mongodb: http://docs.mongodb.org/manual/
In the data package, discussion data is delivered in a .mongo file, identified by organization and course, in this format: edX-*organization*-*course*-*source*.mongo.
In the data package, discussion data is delivered in a .mongo file, identified
by organization and course, in this format:
edX-*organization*-*course*-*source*.mongo.
The primary collection that holds all of the discussion posts written by users is "contents". Two different types of objects are stored, representing the three levels of interactions that users can have in a discussion.
The primary collection that holds all of the discussion posts written by users
is "contents". Two different types of objects are stored, representing the
three levels of interactions that users can have in a discussion.
* A ``CommentThread`` represents the first level of interaction: a post that opens a new thread, often a student question of some sort.
* A ``CommentThread`` represents the first level of interaction: a post that
opens a new thread, often a student question of some sort.
* A ``Comment`` represents both the second and third levels of interaction: a response made directly to the conversation started by a ``CommentThread`` is a ``Comment``. Any further contributions made to a specific response are also in ``Comment`` objects.
* A ``Comment`` represents both the second and third levels of interaction: a
response made directly to the conversation started by a ``CommentThread`` is
a ``Comment``. Any further contributions made to a specific response are also
in ``Comment`` objects.
A sample of the field/value pairs that are in the mongo file, and descriptions of the attributes that these two types of objects share and that are specific to each type, follow.
A sample of the field/value pairs that are in the mongo file, and descriptions
of the attributes that these two types of objects share and that are specific
to each type, follow.
In addition to these collections, events are also emitted to track specific user activities. See :ref:`forum_events`.
In addition to these collections, events are also emitted to track specific
user activities. See :ref:`forum_events`.
*********
Samples
......@@ -36,17 +49,19 @@ machine-readable format that can be difficult to read at a glance.
.. code-block:: json
{ "_id" : { "$oid" : "50f1dd4ae05f6d2600000001" }, "_type" : "CommentThread", "anonymous" :
false, "anonymous_to_peers" : false, "at_position_list" : [], "author_id" : "NNNNNNN",
"author_username" : "AAAAAAAAAA", "body" : "Welcome to the edX101 forum!\n\nThis forum will
be regularly monitored by edX. Please post your questions and comments here. When asking a
question, don't forget to search the forum to check whether your question has already been
answered.\n\n", "closed" : false, "comment_count" : 0, "commentable_id" : "i4x-edX-edX101-
course-How_to_Create_an_edX_Course", "course_id" : "edX/edX101/How_to_Create_an_edX_Course",
"created_at" : { "$date" : 1358028106904 }, "last_activity_at" : { "$date" : 1358134464424 },
"tags_array" : [], "title" : "Welcome to the edX101 forum!", "updated_at" : { "$date" :
1358134453862 }, "votes" : { "count" : 1, "down" : [], "down_count" : 0, "point" : 1, "up" :
[ "48" ], "up_count" : 1 } }
{ "_id" : { "$oid" : "50f1dd4ae05f6d2600000001" }, "_type" : "CommentThread",
"anonymous" :false, "anonymous_to_peers" : false, "at_position_list" : [],
"author_id" : "NNNNNNN","author_username" : "AAAAAAAAAA", "body" : "Welcome to
the edX101 forum!\n\nThis forum willbe regularly monitored by edX. Please post
your questions and comments here. When asking aquestion, don't forget to
search the forum to check whether your question has already
beenanswered.\n\n", "closed" : false, "comment_count" : 0, "commentable_id" :
"i4x-edX-edX101-course-How_to_Create_an_edX_Course", "course_id" :
"edX/edX101/How_to_Create_an_edX_Course","created_at" : { "$date" :
1358028106904 }, "last_activity_at" : { "$date" : 1358134464424 },"tags_array"
: [], "thread_type": "discussion", "title" : "Welcome to the edX101 forum!",
"updated_at" : { "$date" :1358134453862 }, "votes" : { "count" : 1, "down" :
[], "down_count" : 0, "point" : 1, "up" :[ "48" ], "up_count" : 1 } }
If you use a JSON formatter to "pretty print" this document, a version that is
more readable is produced.
......@@ -65,9 +80,10 @@ more readable is produced.
],
"author_id": "NNNNNNN",
"author_username": "AAAAAAAAAA",
"body": "Welcome to the edX101 forum!\n\nThis forum will be regularly monitored by edX. Please
post your questions and comments here. When asking a question, don't forget to search the
forum to check whether your question has already been answered.\n\n",
"body": "Welcome to the edX101 forum!\n\nThis forum will be regularly
monitored by edX. Please post your questions and comments here. When
asking a question, don't forget to search the forum to check whether
your question has already been answered.\n\n",
"closed": false,
"comment_count": 0,
"commentable_id": "i4x-edX-edX101-course-How_to_Create_an_edX_Course",
......@@ -81,6 +97,7 @@ more readable is produced.
"tags_array": [
],
"thread_type": "discussion",
"title": "Welcome to the edX101 forum!",
"updated_at": {
"$date": 1358134453862
......@@ -97,7 +114,7 @@ more readable is produced.
],
"up_count": 1
}
}
}
----------------------------------------
Comment Document Example
......@@ -105,16 +122,20 @@ Comment Document Example
.. code-block:: json
{ "_id" : { "$oid" : "52e54fdd801eb74c33000070" }, "votes" : { "up" : [], "down" : [],
"up_count" : 0, "down_count" : 0, "count" : 0, "point" : 0 }, "visible" : true,
"abuse_flaggers" : [], "historical_abuse_flaggers" : [], "parent_ids" : [], "at_position_list" :
[], "body" : "I'm hoping this Demonstration course will help me figure out how to take the
course I registered for. I am just auditing the course, but I want to benefit from it as much
as possible, as I am extremely interested in it.\n", "course_id" : "edX/DemoX/Demo_Course",
"_type" : "Comment", "endorsed" : false, "anonymous" : false, "anonymous_to_peers" : false,
"author_id" : "NNNNNNN", "comment_thread_id" : { "$oid" : "52e4e880c0df1fa59600004d" },
"author_username" : "AAAAAAAAAA", "sk" : "52e54fdd801eb74c33000070", "updated_at" :
{ "$date" : 1390759901966 }, "created_at" : { "$date" : 1390759901966 } }
{ "_id" : { "$oid" : "52e54fdd801eb74c33000070" }, "votes" : { "up" : [],
"down" : [], "up_count" : 0, "down_count" : 0, "count" : 0, "point" : 0 },
"visible" : true, "abuse_flaggers" : [], "historical_abuse_flaggers" : [],
"parent_ids" : [], "at_position_list" : [], "body" : "I'm hoping this
Demonstration course will help me figure out how to take the course I
registered for. I am just auditing the course, but I want to benefit from it
as much as possible, as I am extremely interested in it.\n", "course_id" :
"edX/DemoX/Demo_Course", "_type" : "Comment", "endorsed" : true, "endorsement"
: { "user_id" : "9", "time" : ISODate("2014-08-29T15:11:49.442Z") },
"anonymous" : false, "anonymous_to_peers" : false, "author_id" : "NNNNNNN",
"comment_thread_id" : { "$oid" : "52e4e880c0df1fa59600004d" },
"author_username" : "AAAAAAAAAA", "sk" : "52e54fdd801eb74c33000070",
updated_at" : { "$date" : 1390759901966 }, "created_at" : { "$date" :
1390759901966 } }
When pretty printed, this comment looks like this:
......@@ -149,12 +170,19 @@ When pretty printed, this comment looks like this:
"at_position_list": [
],
"body": "I'm hoping this Demonstration course will help me figure out how to take the
course I registered for. I am just auditing the course, but I want to benefit from it
as much as possible, as I am extremely interested in it.\n",
"body": "I'm hoping this Demonstration course will help me figure out how
to take the course I registered for. I am just auditing the course, but I
want to benefit from it as much as possible, as I am extremely interested
in it.\n",
"course_id": "edX\/DemoX\/Demo_Course",
"_type": "Comment",
"endorsed": false,
"endorsed": true,
"endorsement": {
"user_id": "9",
"time": {
"$date": 1390759911966
}
}
"anonymous": false,
"anonymous_to_peers": false,
"author_id": "NNNNNNN",
......@@ -169,7 +197,7 @@ When pretty printed, this comment looks like this:
"created_at": {
"$date": 1390759901966
}
}
}
*****************
Shared Fields
......@@ -300,6 +328,13 @@ title
--------------------
Title of the thread. UTF-8 string.
--------------------
thread_type
--------------------
Identifies the type of post as a "question" or "discussion".
**History**: Added 2 Sep 2014.
********************
Comment Fields
********************
......@@ -326,7 +361,24 @@ historical_abuse_flaggers
--------------------
endorsed
--------------------
Boolean value, true if a forum moderator or instructor has marked that this ``Comment`` is a correct answer for whatever question the thread was asking. Exists for Comments that are replies to other Comments, but in that case ``endorsed`` is always false because there's no way to endorse such comments through the UI.
Boolean value. True if a forum moderator has marked this response to a
``CommentThread`` with a ``thread_type`` of "discussion" as a valuable
contribution, or if a forum moderator or the originator of a
``CommentThread`` with a ``thread_type`` of "question" has marked this
response as the correct answer.
The ``endorsed`` field is present for comments that are made as replies to
responses, but in these cases the value is always false: the user interface
does not offer a way to endorse comments.
--------------------
endorsement
--------------------
Contains ``time`` and ``user_id`` fields for the date and time that this
response to a post was endorsed and the numeric user ID (from
``auth_user.id``) of the person who endorsed it.
**History**: Added 2 Sep 2014.
--------------------
comment_thread_id
......
......@@ -217,6 +217,11 @@ on the server. The values in this field are:
**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
===================
......
......@@ -6,7 +6,7 @@ from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.core.management import call_command
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 opaque_keys.edx.locations import SlashSeparatedCourseKey
......@@ -26,9 +26,11 @@ CS_PREFIX = "http://localhost:4567/api/v1"
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):
mock_request.return_value.text = json.dumps(data)
mock_request.return_value.json.return_value = data
mock_request.return_value = self._create_repsonse_mock(data)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
......@@ -72,6 +74,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
def test_create_thread(self, mock_request):
mock_request.return_value.status_code = 200
self._set_mock_request_data(mock_request, {
"thread_type": "discussion",
"title": "Hello",
"body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course",
......@@ -100,11 +103,13 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
"read": False,
"comments_count": 0,
})
thread = {"body": ["this is a post"],
thread = {
"thread_type": "discussion",
"body": ["this is a post"],
"anonymous_to_peers": ["false"],
"auto_subscribe": ["false"],
"anonymous": ["false"],
"title": ["Hello"]
"title": ["Hello"],
}
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': self.course_id.to_deprecated_string()})
......@@ -114,6 +119,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
'post',
'{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX),
data={
'thread_type': 'discussion',
'body': u'this is a post',
'anonymous_to_peers': False, 'user_id': 1,
'title': u'Hello',
......@@ -616,6 +622,53 @@ class ViewPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
)
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)
class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRequestSetupMixin):
......@@ -628,7 +681,7 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq
@patch('lms.lib.comment_client.utils.requests.request')
def _test_unicode_data(self, text, 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.view_name = "create_thread"
response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable")
......
......@@ -53,11 +53,11 @@ def permitted(fn):
return wrapper
def ajax_content_response(request, course_id, content):
def ajax_content_response(request, course_key, content):
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = get_annotated_content_info(course_id, content, request.user, user_info)
annotated_content_info = get_annotated_content_info(course_key, content, request.user, user_info)
return JsonResponse({
'content': safe_content(content),
'content': safe_content(content, course_key),
'annotated_content_info': annotated_content_info,
})
......@@ -71,8 +71,8 @@ def create_thread(request, course_id, commentable_id):
"""
log.debug("Creating new thread in %r, id %r", course_id, commentable_id)
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(request.user, 'load', course_id)
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(request.user, 'load', course_key)
post = request.POST
if course.allow_anonymous:
......@@ -94,8 +94,9 @@ def create_thread(request, course_id, commentable_id):
anonymous=anonymous,
anonymous_to_peers=anonymous_to_peers,
commentable_id=commentable_id,
course_id=course_id.to_deprecated_string(),
course_id=course_key.to_deprecated_string(),
user_id=request.user.id,
thread_type=post["thread_type"],
body=post["body"],
title=post["title"]
)
......@@ -107,13 +108,13 @@ def create_thread(request, course_id, commentable_id):
#not anymore, only for admins
# Cohort the thread if the commentable is cohorted.
if is_commentable_cohorted(course_id, commentable_id):
user_group_id = get_cohort_id(user, course_id)
if is_commentable_cohorted(course_key, commentable_id):
user_group_id = get_cohort_id(user, course_key)
# TODO (vshnayder): once we have more than just cohorts, we'll want to
# change this to a single get_group_for_user_and_commentable function
# that can do different things depending on the commentable_id
if cached_has_permission(request.user, "see_all_cohorts", course_id):
if cached_has_permission(request.user, "see_all_cohorts", course_key):
# admins can optionally choose what group to post as
group_id = post.get('group_id', user_group_id)
else:
......@@ -135,9 +136,9 @@ def create_thread(request, course_id, commentable_id):
data = thread.to_dict()
add_courseware_context([data], course)
if request.is_ajax():
return ajax_content_response(request, course_id, data)
return ajax_content_response(request, course_key, data)
else:
return JsonResponse(safe_content(data))
return JsonResponse(safe_content(data, course_key))
@require_POST
......@@ -151,19 +152,20 @@ def update_thread(request, course_id, thread_id):
return JsonError(_("Title can't be empty"))
if 'body' not in request.POST or not request.POST['body'].strip():
return JsonError(_("Body can't be empty"))
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
thread = cc.Thread.find(thread_id)
thread.body = request.POST["body"]
thread.title = request.POST["title"]
thread.save()
if request.is_ajax():
return ajax_content_response(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), thread.to_dict())
return ajax_content_response(request, course_key, thread.to_dict())
else:
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
def _create_comment(request, course_key, thread_id=None, parent_id=None):
"""
given a course_id, thread_id, and parent_id, create a comment,
given a course_key, thread_id, and parent_id, create a comment,
called from create_comment to do the actual creation
"""
assert isinstance(course_key, CourseKey)
......@@ -199,7 +201,7 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None):
if request.is_ajax():
return ajax_content_response(request, course_key, comment.to_dict())
else:
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course.id))
@require_POST
......@@ -224,9 +226,10 @@ def delete_thread(request, course_id, thread_id):
given a course_id and thread_id, delete this thread
this is ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
thread = cc.Thread.find(thread_id)
thread.delete()
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -237,15 +240,16 @@ def update_comment(request, course_id, comment_id):
given a course_id and comment_id, update the comment with payload attributes
handles static and ajax submissions
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
comment = cc.Comment.find(comment_id)
if 'body' not in request.POST or not request.POST['body'].strip():
return JsonError(_("Body can't be empty"))
comment.body = request.POST["body"]
comment.save()
if request.is_ajax():
return ajax_content_response(request, SlashSeparatedCourseKey.from_deprecated_string(course_id), comment.to_dict())
return ajax_content_response(request, course_key, comment.to_dict())
else:
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -256,10 +260,12 @@ def endorse_comment(request, course_id, comment_id):
given a course_id and comment_id, toggle the endorsement of this comment,
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
comment = cc.Comment.find(comment_id)
comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true'
comment.endorsement_user_id = request.user.id
comment.save()
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -270,13 +276,14 @@ def openclose_thread(request, course_id, thread_id):
given a course_id and thread_id, toggle the status of this thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
thread = cc.Thread.find(thread_id)
thread.closed = request.POST.get('closed', 'false').lower() == 'true'
thread.save()
thread = thread.to_dict()
return JsonResponse({
'content': safe_content(thread),
'ability': get_ability(SlashSeparatedCourseKey.from_deprecated_string(course_id), thread, request.user),
'content': safe_content(thread, course_key),
'ability': get_ability(course_key, thread, request.user),
})
......@@ -302,9 +309,10 @@ def delete_comment(request, course_id, comment_id):
given a course_id and comment_id delete this comment
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
comment = cc.Comment.find(comment_id)
comment.delete()
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -314,10 +322,11 @@ def vote_for_comment(request, course_id, comment_id, value):
"""
given a course_id and comment_id,
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.vote(comment, value)
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -328,10 +337,11 @@ def undo_vote_for_comment(request, course_id, comment_id):
given a course id and comment id, remove vote
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
user.unvote(comment)
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -342,10 +352,11 @@ def vote_for_thread(request, course_id, thread_id, value):
given a course id and thread id vote for this thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.vote(thread, value)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -356,10 +367,11 @@ def flag_abuse_for_thread(request, course_id, thread_id):
given a course_id and thread_id flag this thread for abuse
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.flagAbuse(user, thread)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -371,12 +383,12 @@ def un_flag_abuse_for_thread(request, course_id, thread_id):
ajax only
"""
user = cc.User.from_django_user(request.user)
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_by_id(course_id)
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_by_id(course_key)
thread = cc.Thread.find(thread_id)
remove_all = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, 'staff', course)
remove_all = cached_has_permission(request.user, 'openclose_thread', course_key) or has_access(request.user, 'staff', course)
thread.unFlagAbuse(user, thread, remove_all)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -387,10 +399,11 @@ def flag_abuse_for_comment(request, course_id, comment_id):
given a course and comment id, flag comment for abuse
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
comment = cc.Comment.find(comment_id)
comment.flagAbuse(user, comment)
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -407,7 +420,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id):
remove_all = cached_has_permission(request.user, 'openclose_thread', course_key) or has_access(request.user, 'staff', course)
comment = cc.Comment.find(comment_id)
comment.unFlagAbuse(user, comment, remove_all)
return JsonResponse(safe_content(comment.to_dict()))
return JsonResponse(safe_content(comment.to_dict(), course_key))
@require_POST
......@@ -418,10 +431,11 @@ def undo_vote_for_thread(request, course_id, thread_id):
given a course id and thread id, remove users vote for thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unvote(thread)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -432,10 +446,11 @@ def pin_thread(request, course_id, thread_id):
given a course id and thread id, pin this thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.pin(user, thread_id)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......@@ -446,10 +461,11 @@ def un_pin_thread(request, course_id, thread_id):
given a course id and thread id, remove pin from this thread
ajax only
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
thread.un_pin(user, thread_id)
return JsonResponse(safe_content(thread.to_dict()))
return JsonResponse(safe_content(thread.to_dict(), course_key))
@require_POST
......
......@@ -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
query_params = merge_dict(default_query_params,
strip_none(extract(request.GET,
['page', 'sort_key',
'sort_order', 'text',
'commentable_ids', 'flagged'])))
query_params = merge_dict(
default_query_params,
strip_none(
extract(
request.GET,
[
'page',
'sort_key',
'sort_order',
'text',
'commentable_ids',
'flagged',
'unread',
'unanswered',
]
)
)
)
threads, page, num_pages, corrected_text = cc.Thread.search(query_params)
......@@ -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)
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
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,
'annotated_content_info': annotated_content_info,
'page': query_params['page'],
......@@ -173,7 +186,7 @@ def forum_form_discussion(request, course_id):
try:
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)
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:
log.warning("Forum is in maintenance mode")
return render_to_response('discussion/maintenance.html', {})
......@@ -253,7 +266,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if request.is_ajax():
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
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"):
add_courseware_context([content], course)
return utils.JsonResponse({
......@@ -276,7 +289,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if not "pinned" in thread:
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"):
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):
if request.is_ajax():
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
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'],
'num_pages': query_params['num_pages'],
'annotated_content_info': _attr_safe_json(annotated_content_info),
......@@ -368,13 +381,30 @@ def followed_threads(request, course_id, user_id):
try:
profiled_user = cc.User(id=user_id, course_id=course_id)
query_params = {
'page': request.GET.get('page', 1),
default_query_params = {
'page': 1,
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
'sort_key': request.GET.get('sort_key', 'date'),
'sort_order': request.GET.get('sort_order', 'desc'),
'sort_key': 'date',
'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)
query_params['page'] = page
query_params['num_pages'] = num_pages
......@@ -386,7 +416,7 @@ def followed_threads(request, course_id, user_id):
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({
'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'],
'num_pages': query_params['num_pages'],
})
......
......@@ -5,6 +5,7 @@ Module for checking permissions with the comment_client backend
import logging
from types import NoneType
from django.core import cache
from lms.lib.comment_client import Thread
from opaque_keys.edx.keys import CourseKey
CACHE = cache.get_cache('default')
......@@ -34,31 +35,44 @@ def has_permission(user, permission, course_id=None):
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_open(user, condition, course_id, data):
def _check_condition(user, condition, content):
def check_open(user, content):
try:
return data and not data['content']['closed']
return content and not content['closed']
except KeyError:
return False
def check_author(user, condition, course_id, data):
def check_author(user, content):
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:
return False
handlers = {
'is_open': check_open,
'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.
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):
def test(user, per, operator="or"):
if isinstance(per, basestring):
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)
elif isinstance(per, list) and operator in ["and", "or"]:
results = [test(user, x, operator="and") for x in per]
......@@ -85,7 +99,7 @@ VIEW_PERMISSIONS = {
'create_comment': [["create_comment", "is_open"]],
'delete_thread': ['delete_thread', ['update_thread', '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'],
'create_sub_comment': [['create_sub_comment', 'is_open']],
'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']],
......@@ -115,4 +129,4 @@ def check_permissions_by_view(user, course_id, content, name):
p = VIEW_PERMISSIONS[name]
except KeyError:
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
from django.http import HttpResponse
from django.utils import simplejson
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
import pystache_custom as pystache
......@@ -258,7 +258,6 @@ def get_ability(course_id, content, user):
return {
'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_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_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"),
......@@ -293,7 +292,11 @@ def get_annotated_content_infos(course_id, thread, user, user_info):
def annotate(content):
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(thread)
return infos
......@@ -361,7 +364,7 @@ def add_courseware_context(content_list, course):
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 = [
'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
......@@ -369,15 +372,42 @@ def safe_content(content, is_staff=False):
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'unread_comments_count',
'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers',
'stats', '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):
fields += ['username', 'user_id']
if 'children' in content:
safe_children = [safe_content(child) for child in content['children']]
content['children'] = safe_children
content = strip_none(extract(content, fields))
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
......@@ -11,12 +11,12 @@ class Comment(models.Model):
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id', 'abuse_flaggers'
'type', 'commentable_id', 'abuse_flaggers', 'endorsement',
]
updatable_fields = [
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed'
'user_id', 'endorsed', 'endorsement_user_id',
]
initializable_fields = updatable_fields
......
......@@ -35,7 +35,7 @@ class Model(object):
return self.__getattr__(name)
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)
else:
self.attributes[name] = value
......@@ -46,7 +46,7 @@ class Model(object):
return self.attributes.get(key)
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))
self.attributes.__setitem__(key, value)
......
......@@ -16,7 +16,8 @@ class Thread(models.Model):
'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title',
'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 = [
......@@ -29,7 +30,7 @@ class Thread(models.Model):
'endorsed', 'read'
]
initializable_fields = updatable_fields
initializable_fields = updatable_fields + ['thread_type']
base_url = "{prefix}/threads".format(prefix=settings.PREFIX)
default_retrieve_params = {'recursive': False}
......
......@@ -49,8 +49,15 @@
// applications
@import "discussion/utilities/variables";
@import "discussion/mixins";
@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/views/thread";
@import "discussion/views/new-post";
@import "discussion/views/response";
@import 'discussion/utilities/developer';
@import 'discussion/utilities/shame';
......
......@@ -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
$black: rgb(0,0,0);
$black-t0: rgba($black, 0.125);
......
.discussion-module {
@extend .discussion-body;
margin: 20px 0;
padding: 20px 20px 28px 20px;
background: #f6f6f6 !important;
border-radius: 3px;
.responses {
margin-top: 40px;
> li {
margin: 0 20px 30px;
}
}
.discussion-show {
display: block;
width: 200px;
margin: auto;
font-size: 14px;
text-align: center;
&.shown {
.show-hide-discussion-icon {
background-position: 0 0;
}
}
.show-hide-discussion-icon {
display: inline-block;
position: relative;
top: 5px;
margin-right: 6px;
width: 21px;
height: 19px;
background: url(../images/show-hide-discussion-icon.png) no-repeat;
background-position: -21px 0;
}
}
.new-post-btn {
display: inline-block;
}
section.discussion {
margin-top: 20px;
.threads {
margin-top: 20px;
}
/* Course content p has a default margin-bottom of 1.416em, this is just to reset that */
.discussion-thread {
padding: 0;
@include transition(all .25s);
.dogear,
.vote-btn {
display: none;
}
&.expanded {
padding: 20px 0;
.dogear,
.vote-btn {
display: block;
}
.discussion-article {
border: 1px solid #b2b2b2;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
}
p {
margin-bottom: 0em;
}
.discussion-article {
border: 1px solid #ddd;
border-bottom-width: 0;
background: #fff;
min-height: 0;
padding: 10px 10px 15px 10px;
box-shadow: 0 1px 0 #ddd;
@include transition(all .2s);
.discussion-post {
padding: 12px 20px 0 20px;
@include clearfix;
header {
padding-bottom: 0;
margin-bottom: 15px;
h3 {
font-size: 19px;
font-weight: 700;
margin-bottom: 0px;
}
h4 {
font-size: 16px;
}
}
.post-body {
font-size: 14px;
clear: both;
}
}
.post-tools {
margin-left: 20px;
a {
display: block;
font-size: 12px;
line-height: 30px;
&.expand-post:before {
content: '▾ ';
}
&.collapse-post:before {
content: '▴ ';
}
&.collapse-post {
display: none;
}
}
}
.responses {
margin-top: 10px;
header {
padding-bottom: 0em;
margin-bottom: 5px;
.posted-by {
font-size: 0.8em;
}
}
.response-body {
margin-bottom: 0.2em;
font-size: 14px;
}
}
.discussion-reply-new {
.wmd-input {
height: 120px;
}
}
// Content that is hidden by default in the inline view
.post-extended-content{
display: none;
}
}
}
}
.new-post-article {
display: none;
margin-top: 20px;
.inner-wrapper {
max-width: 1180px;
min-width: 760px;
margin: auto;
}
.new-post-form {
width: 100%;
margin-bottom: 20px;
padding: 30px;
border-radius: 3px;
background: rgba(0, 0, 0, .55);
color: #fff;
box-shadow: none;
@include clearfix;
@include box-sizing(border-box);
.form-row {
margin-bottom: 20px;
}
.new-post-body .wmd-input {
@include discussion-wmd-input;
position: relative;
width: 100%;
height: 200px;
z-index: 1;
padding: 10px;
box-sizing: border-box;
border: 1px solid #333;
border-radius: 3px 3px 0 0;
background: #fff;
font-family: 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
.new-post-body .wmd-preview {
@include discussion-wmd-preview;
position: relative;
width: 100%;
//height: 50px;
margin-top: -1px;
padding: 25px 20px 10px 20px;
box-sizing: border-box;
border: 1px solid #333;
border-radius: 0 0 3px 3px;
background: #e6e6e6;
color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
.new-post-preview-label {
position: absolute;
top: 4px;
left: 4px;
font-size: 11px;
color: #aaa;
text-transform: uppercase;
}
.new-post-title{
width: 100%;
height: 40px;
padding: 0 10px;
box-sizing: border-box;
border-radius: 3px;
border: 1px solid #333;
font-size: 16px;
font-weight: 700;
font-family: 'Open Sans', sans-serif;
color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
.submit {
@include blue-button;
float: left;
height: 37px;
margin-top: 10px;
padding-bottom: 2px;
border-color: #333;
&:hover, &:focus {
border-color: #222;
}
}
.new-post-cancel {
@include white-button;
float: left;
margin: 10px 0 0 15px;
border-color: #444;
}
.options {
margin-top: 5px;
label {
display: inline;
margin-left: 8px;
font-size: 15px;
color: #fff;
text-shadow: none;
}
}
}
.thread-title {
display: block;
margin-bottom: 20px;
font-size: 21px;
color: #333;
font-weight: 700;
}
}
.new-post-btn {
@include blue-button;
display: inline-block;
font-size: 13px;
margin-right: 4px;
}
.new-post-icon {
display: block;
float: left;
width: 16px;
height: 17px;
margin: 8px 7px 0 0;
font-size: 16px;
vertical-align: middle;
color: $white;
}
.moderator-actions {
padding-left: 0 !important;
}
section.pagination {
margin-top: 30px;
nav.discussion-paginator {
float: right;
ol {
li {
list-style: none;
display: inline-block;
padding-right: 0.5em;
a {
@include white-button;
}
}
li.current-page{
height: 35px;
padding: 0 15px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 13px;
font-weight: 700;
line-height: 32px;
color: #333;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
}
}
}
}
.new-post-body {
.wmd-panel {
width: 100%;
min-width: 500px;
}
.wmd-button-bar {
width: 100%;
}
.wmd-input {
height: 150px;
width: 100%;
background-color: #e9e9e9;
border: 1px solid #c8c8c8;
font-family: Monaco, 'Lucida Console', monospace;
font-style: normal;
font-size: 0.8em;
line-height: 1.6em;
@include border-radius(3px 3px 0 0);
&::-webkit-input-placeholder {
color: #888;
}
}
.wmd-preview {
position: relative;
font-family: $sans-serif;
padding: 25px 20px 10px 20px;
margin-bottom: 5px;
box-sizing: border-box;
border: 1px solid #c8c8c8;
border-top-width: 0;
@include border-radius(0 0 3px 3px);
overflow: hidden;
@include transition(all, .2s, easeOut);
&:before {
content: 'PREVIEW';
position: absolute;
top: 3px;
left: 5px;
font-size: 11px;
color: #bbb;
}
p {
font-family: $sans-serif;
}
background-color: #fafafa;
}
.wmd-button-row {
position: relative;
margin-left: 5px;
margin-right: 5px;
margin-bottom: 5px;
margin-top: 10px;
padding: 0px;
height: 20px;
overflow: hidden;
@include transition(all, .2s, easeOut);
}
.wmd-spacer {
width: 1px;
height: 20px;
margin-left: 14px;
position: absolute;
background-color: Silver;
display: inline-block;
list-style: none;
}
.wmd-button {
width: 20px;
height: 20px;
padding-left: 2px;
padding-right: 3px;
position: absolute;
display: inline-block;
list-style: none;
cursor: pointer;
background: none;
}
.wmd-button > span {
display: inline-block;
background-image: url(../images/new-post-icons-full.png);
background-repeat: no-repeat;
background-position: 0px 0px;
width: 20px;
height: 20px;
}
.wmd-spacer1 {
left: 50px;
}
.wmd-spacer2 {
left: 175px;
}
.wmd-spacer3 {
left: 300px;
}
.wmd-prompt-background {
background-color: Black;
}
.wmd-prompt-dialog {
@extend .modal;
background: #fff;
}
.wmd-prompt-dialog {
padding: 20px;
> 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
display: none;
position: relative;
height: 12px;
}
.wmd-button {
span {
background-image: url("/static/images/wmd-buttons.png");
display: inline-block;
}
}
}
\ No newline at end of file
// forums - main styling
// forums - main app styling
// ====================
// mixins and extends
@mixin blue-button {
display: block;
height: 35px;
padding: 0 ($baseline*.75);
border-radius: 3px;
border: 1px solid #2d81ad;
@include linear-gradient(top, #6dccf1, #38a8e5);
font-size: 13px;
font-weight: 700;
line-height: 32px;
color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, .3);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
&:hover, &:focus {
border-color: #297095;
@include linear-gradient(top, #4fbbe4, #2090d0);
}
}
@mixin white-button {
@include linear-gradient(top, #eee, #ccc);
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: 0 1px 0 rgba(255, 255, 255, 0.6);
font-weight: 700;
font-size: 13px;
line-height: 32px;
&:hover, &:focus {
@include linear-gradient(top, $white, #ddd);
}
}
@mixin dark-grey-button {
display: block;
height: 35px;
padding: 0 ($baseline*.75);
border-radius: 3px;
border: 1px solid #222;
background: -webkit-linear-gradient(top, #777, #555);
font-size: 13px;
font-weight: 700;
line-height: 32px;
color: $white;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.6);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
&:hover, &:focus {
background: -webkit-linear-gradient(top, #888, #666);
}
}
@mixin discussion-wmd-input {
width: 100%;
height: 240px;
margin-top: 0;
padding: ($baseline/2);
@include box-sizing(border-box);
border: 1px solid #aaa;
border-radius: 3px 3px 0 0;
background: $white;
font-family: 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
}
@mixin discussion-wmd-preview-container {
width: 100%;
@include box-sizing(border-box);
border: 1px solid #aaa;
border-top: none;
border-radius: 0 0 3px 3px;
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 {
width: 100%;
padding-top: 3px;
padding-left: 5px;
color: #bbb;
font-size: 11px;
text-transform: uppercase;
}
@mixin discussion-wmd-preview {
width: 100%;
padding: 10px 20px;
color: #333;
}
@-webkit-keyframes fadeIn {
0% { opacity: 0.0; }
100% { opacity: 1.0; }
}
// ===============
// main styling
body.discussion {
// new post creation
.new-post-form-errors {
display: none;
background: $error-red;
padding: 0;
border: 1px solid $dark-gray;
list-style: none;
color: $white;
line-height: 1.6;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset, 0 1px 0 rgba(255, 255, 255, .2);
li {
padding: ($baseline/2) $baseline 12px 45px;
border-bottom: 1px solid #dc4949;
background: url(../images/white-error-icon.png) no-repeat 15px 14px;
&:last-child {
border-bottom: none;
}
}
}
.course-tabs .right {
float: right;
......@@ -155,148 +18,6 @@ body.discussion {
}
}
.new-post-article {
display: none;
margin-top: $baseline;
.inner-wrapper {
max-width: 1180px;
min-width: 760px;
margin: auto;
}
.left-column {
@include box-sizing(border-box);
float: left;
padding: ($baseline*2);
width: 32%;
.topic-dropdown-label {
font-size: 22px;
font-weight: 700;
color: $white;
text-shadow: none;
}
.form-topic-drop {
position: relative;
ul {
list-style: none;
margin: 0;
padding: 0;
}
}
.form-group-label {
display: block;
padding-top: ($baseline/4);
color: $white;
}
.topic_dropdown_button {
@include white-button;
position: relative;
z-index: 1000;
margin-top: 15px;
border-color: #444;
height: 40px;
line-height: 36px;
.drop-arrow {
float: right;
color: #999;
line-height: 37px;
}
}
.topic_menu_wrapper {
display: none;
position: absolute;
top: $baseline*2;
left: 0;
z-index: 9999;
width: 100%;
@include box-sizing(border-box);
background: #797979;
border: 1px solid $dark-gray;
box-shadow: 0 2px 50px rgba(0, 0, 0, .4);
}
.topic_menu {
max-height: 400px;
overflow-y: scroll;
a {
display: block;
padding: ($baseline/2) 15px;
border-top: 1px solid #5f5f5f;
font-size: 14px;
font-weight: 700;
line-height: 18px;
color: #eee;
@include transition(none);
&:hover, &:focus {
background-color: #666;
}
.topic-menu-span {
color: #eee;
}
}
li li {
a {
padding-left: 39px;
background: url(../images/nested-icon.png) no-repeat 17px 10px;
}
}
li li li {
a {
padding-left: 63px;
background: url(../images/nested-icon.png) no-repeat 41px 10px;
}
}
}
.topic_menu_search {
padding: $baseline/2;
border-bottom: 1px solid black;
}
.form-topic-drop-search-input {
width: 100%;
height: 30px;
padding: 0 15px;
@include box-sizing(border-box);
border-radius: 30px;
border: 1px solid $dark-gray;
box-shadow: 0 1px 3px rgba(0, 0, 0, .25) inset;
background: -webkit-linear-gradient(top, #eee, $white);
font-size: 11px;
line-height: 16px;
color: #333;
}
}
.right-column {
float: left;
width: 68%;
padding: ($baseline*2);
@include box-sizing(border-box);
}
.wmd-button {
background: none;
}
.wmd-button span {
background: url(../images/new-post-icons-full.png) no-repeat;
}
}
.edit-post-form {
@include clearfix;
margin-bottom: ($baseline*2);
......@@ -341,92 +62,12 @@ body.discussion {
font-size: 16px;
font-family: $sans-serif;
}
}
.comments .edit-post-form h1 {
@extend %t-title6;
}
.new-post-form {
@include clearfix;
border-radius: 3px;
width: 100%;
background: $shadow-d2;
box-shadow: 0 1px 2px $shadow-d2 inset, 0 1px 0 rgba(255, 255, 255, .5);
color: $white;
.form-row {
margin-bottom: $baseline;
}
.new-post-body .wmd-input {
@include discussion-wmd-input;
@include box-sizing(border-box);
position: relative;
z-index: 1;
width: 100%;
height: 150px;
background: $white;
}
.new-post-body .wmd-preview-container {
@include discussion-new-post-wmd-preview-container;
}
.new-post-body .wmd-preview-label {
@include discussion-wmd-preview-label;
}
.new-post-body .wmd-preview {
@include discussion-wmd-preview;
}
.new-post-title {
@include box-sizing(border-box);
border: 1px solid $dark-gray;
border-radius: 3px;
padding: 0 ($baseline/2);
width: 100%;
height: 40px;
box-shadow: 0 1px 3px $shadow inset;
color: $dark-gray;
font-weight: 700;
}
.submit {
@include blue-button;
float: left;
margin-top: ($baseline/2);
border-color: $dark-gray;
padding-bottom: ($baseline/10);
height: 37px;
&:hover, &:focus {
border-color: #222;
}
}
.new-post-cancel {
@include white-button;
float: left;
margin: ($baseline/2) 0 0 ($baseline*.75);
border-color: #444;
}
.options {
margin-top: ($baseline*2);
label {
display: inline;
margin-left: ($baseline/2);
color: $white;
text-shadow: none;
font-size: 15px;
}
}
}
.thread-title {
display: block;
margin-bottom: $baseline;
......@@ -467,9 +108,6 @@ body.discussion {
}
}
.wmd-panel {
min-width: 500px;
width: 100%;
......@@ -776,7 +414,7 @@ body.discussion {
.discussion-article {
position: relative;
min-height: 468px;
min-height: 500px;
background-image: url(../images/bg-texture.png);
a {
......@@ -784,7 +422,7 @@ body.discussion {
}
h1 {
margin-bottom: $baseline/2;
margin-bottom: ($baseline/4);
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
......@@ -793,17 +431,14 @@ body.discussion {
.posted-details {
font-size: 12px;
font-style: italic;
color: #888;
.username {
display: block;
font-size: 16px;
font-weight: 700;
}
span {
font-style: italic;
.timeago, .top-post-status {
color: inherit;
}
}
......@@ -816,53 +451,18 @@ body.discussion {
p + p {
margin-top: $baseline;
}
.dogear {
display: block;
position: absolute;
top: -1px;
right: -1px;
width: 52px;
height: 51px;
background: url(../images/follow-dog-ear.png) 0 -52px no-repeat;
@include transition(none);
&.is-followed {
background-position: 0 0;
}
}
.discussion-post header,
.responses li header {
margin-bottom: $baseline;
}
.discussion-post {
padding: ($baseline*2) ($baseline*2) $baseline ($baseline*2);
box-shadow: 0 1px 3px $shadow;
background-color: $white;
border-radius: 3px 3px 0 0;
> header .vote-btn {
position: relative;
z-index: 100;
margin-top: ($baseline/4);
margin-left: ($baseline*2);
.responses {
&:empty {
display: none;
}
.post-tools {
@include clearfix;
margin-top: 15px;
}
}
.discussion-post header,
.responses li header {
margin-bottom: $baseline;
}
.responses {
list-style: none;
margin-top: $baseline;
padding: 0px ($baseline*2);
......@@ -917,7 +517,6 @@ body.discussion {
text-transform: uppercase;
}
&.loading {
height: 0;
margin: 0;
......@@ -930,7 +529,7 @@ body.discussion {
.discussion-response {
@include box-sizing(border-box);
border-radius: 3px 3px 0 0;
padding: $baseline $baseline 0;
padding: $baseline;
background-color: $white;
}
.posted-by {
......@@ -959,79 +558,6 @@ body.discussion {
}
}
.vote-btn {
position: relative;
z-index: 100;
float: right;
display: block;
height: 27px;
padding: 0 8px;
border-radius: 5px;
border: 1px solid #b2b2b2;
@include linear-gradient(top, $white 35%, #ebebeb);
box-shadow: 0 1px 1px rgba(0, 0, 0, .15);
font-size: 12px;
font-weight: 700;
line-height: 25px;
color: #333;
.plus-icon {
display: inline-block;
width: 10px;
height: 10px;
margin: 8px 6px 0 0;
background: url(../images/vote-plus-icon.png) no-repeat;
font-size: 18px;
text-indent: -9999px;
color: #17b429;
overflow: hidden;
}
&.is-cast {
border-color: #379a42;
@include linear-gradient(top, #50cc5e, #3db84b);
color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, .3);
box-shadow: 0 1px 0 rgba(255, 255, 255, .4) inset, 0 1px 2px $shadow;
.plus-icon {
background-position: 0 -10px;
color: #336a39;
text-shadow: 0 1px 0 rgba(255, 255, 255, .4);
}
}
}
.endorse-btn {
display: block;
float: right;
width: 27px;
height: 27px;
margin-right: ($baseline/2);
border-radius: 27px;
border: 1px solid #a0a0a0;
@include linear-gradient(top, $white 35%, $gray-l4);
box-shadow: 0 1px 1px $shadow-l1;
.check-icon {
display: block;
width: 13px;
height: 12px;
margin: 8px auto;
background: url(../images/endorse-icon.png) no-repeat;
}
&.is-endorsed {
border: 1px solid #4697c1;
@include linear-gradient(top, #6dccf1, #38a8e5);
box-shadow: 0 1px 1px $shadow-l1, 0 1px 0 rgba(255, 255, 255, .4) inset;
.check-icon {
background-position: 0 -12px;
}
}
}
blockquote {
background: $gray-l5;
border-radius: 3px;
......@@ -1039,89 +565,6 @@ body.discussion {
font-size: 14px;
}
.comments {
margin: 0;
border-radius: 0 0 3px 3px;
padding: 0;
background: $gray-l6;
box-shadow: 0 1px 3px -1px $shadow inset;
list-style: none;
> li {
border-top: 1px solid $gray-l4;
padding: ($baseline/2) $baseline;
}
blockquote {
background: $gray-l4;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
font-size: 14px;
}
.comment-form {
@include clearfix;
.comment-form-input {
padding: ($baseline/4) ($baseline/2);
background-color: $white;
font-size: 14px;
}
.discussion-submit-comment {
@include blue-button;
float: left;
margin-top: 8px;
}
.wmd-input {
height: 40px;
}
.discussion-errors {
margin: 0;
}
}
.response-body {
font-size: 13px;
margin-bottom: ($baseline/2);
p + p {
margin-top: 12px;
}
}
.posted-details {
font-size: 11px;
}
.staff-label {
margin-left: ($baseline/10);
padding: 0 ($baseline/5);
border-radius: 2px;
background: #009FE2;
font-size: 9px;
font-weight: 700;
font-style: normal;
color: white;
text-transform: uppercase;
}
}
.community-ta-label{
margin-left: ($baseline/10);
padding: 0 ($baseline/5);
border-radius: 2px;
background: $forum-color-community-ta;
font-size: 9px;
font-weight: 700;
font-style: normal;
color: white;
text-transform: uppercase;
}
.comment-form {
padding: ($baseline/2) 0;
......@@ -1153,85 +596,11 @@ body.discussion {
}
}
.moderator-actions {
margin: 0;
padding: $baseline 0;
@include clearfix;
li {
float: left;
margin-right: ($baseline/2);
list-style: none;
}
a {
@include white-button;
height: 26px;
@include linear-gradient(top, $white 35%, #ebebeb);
font-size: 13px;
line-height: 24px;
color: #737373;
font-weight: normal;
box-shadow: 0 1px 1px $shadow-l1;
&:hover, &:focus {
@include linear-gradient(top, $white 35%, #ddd);
}
.delete-icon {
display: block;
float: left;
width: 10px;
height: 10px;
margin: 8px 4px 0 0;
background: url(../images/moderator-delete-icon.png) no-repeat;
}
.edit-icon {
display: block;
float: left;
width: 10px;
height: 10px;
margin: 7px 4px 0 0;
background: url(../images/moderator-edit-icon.png) no-repeat;
}
}
}
.main-article.new {
display: none;
padding: ($baseline*2.5);
}
.new-post-form {
margin-top: $baseline;
@include clearfix;
}
.new-post-form .submit {
@include blue-button;
float: left;
margin-top: ($baseline/2);
padding-bottom: ($baseline/10);
}
.new-post-form .options {
float: right;
margin-top: $baseline;
font-size: 14px;
label {
margin-left: ($baseline/5);
}
}
.discussion-reply-new {
padding: $baseline ($baseline*1.5);
@include clearfix;
......@@ -1279,16 +648,6 @@ body.discussion {
// ====================
// post actions -global
.global-discussion-actions {
height: 60px;
@include linear-gradient(top, #ebebeb, #d9d9d9);
border-radius: 0 3px 0 0;
border-bottom: 1px solid #bcbcbc;
}
// ====================
// inline discussion module and profile thread styling
.discussion-module {
@extend .discussion-body;
......@@ -1372,16 +731,6 @@ body.discussion {
margin-bottom: $baseline;
@include transition(all .25s linear 0s);
.dogear {
display: none;
}
&.expanded {
.dogear{
display: block;
}
}
p {
margin-bottom: 0;
}
......@@ -1403,7 +752,8 @@ body.discussion {
max-height: 600px;
.group-visibility-label {
margin: $baseline ($baseline*1.5) ($baseline*-0.5);
font-weight: 400;
margin-bottom: ($baseline*0.5);
}
.discussion-post {
......@@ -1439,10 +789,10 @@ body.discussion {
}
}
h3 {
h1 {
font-size: 19px;
font-weight: 700;
margin-bottom: 0px;
margin-bottom: 0px !important; // Override courseware CSS
}
h4 {
......@@ -1524,97 +874,6 @@ body.discussion {
margin: auto;
}
.new-post-form {
width: 100%;
margin-bottom: $baseline;
padding: 30px;
border-radius: 3px;
background: rgba(0, 0, 0, .55);
color: $white;
box-shadow: none;
@include clearfix;
@include box-sizing(border-box);
.form-row {
margin-bottom: $baseline;
}
.new-post-body .wmd-input {
@include discussion-wmd-input;
position: relative;
width: 100%;
height: 200px;
z-index: 1;
padding: $baseline/2;
@include box-sizing(border-box);
border: 1px solid #333;
border-radius: 3px 3px 0 0;
background: $white;
font-family: 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
.new-post-body .wmd-preview-container {
@include discussion-new-post-wmd-preview-container;
}
.new-post-body .wmd-preview-label {
@include discussion-wmd-preview-label;
}
.new-post-body .wmd-preview {
@include discussion-wmd-preview;
}
.new-post-title{
width: 100%;
height: 40px;
padding: 0 $baseline/2;
@include box-sizing(border-box);
border-radius: 3px;
border: 1px solid #333;
font-size: 16px;
font-weight: 700;
font-family: 'Open Sans', sans-serif;
color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
.submit {
@include blue-button;
float: left;
height: 37px;
margin-top: $baseline/2;
padding-bottom: 2px;
border-color: #333;
&:hover, &:focus {
border-color: #222;
}
}
.new-post-cancel {
@include white-button;
float: left;
margin: $baseline/2 0 0 15px;
border-color: #444;
}
.options {
margin-top: 5px;
label {
display: inline;
margin-left: 8px;
font-size: 15px;
color: $white;
text-shadow: none;
}
}
}
.thread-title {
display: block;
margin-bottom: $baseline;
......@@ -1643,10 +902,6 @@ body.discussion {
color: $white;
}
.moderator-actions {
padding-left: 0 !important;
}
section.pagination {
margin-top: 30px;
......@@ -1678,148 +933,6 @@ body.discussion {
}
}
.new-post-body {
.wmd-panel {
width: 100%;
min-width: 500px;
}
.wmd-button-bar {
width: 100%;
}
.wmd-input {
height: 150px;
width: 100%;
background-color: #e9e9e9;
border: 1px solid #c8c8c8;
font-family: Monaco, 'Lucida Console', monospace;
font-style: normal;
font-size: 0.8em;
line-height: 1.6em;
border-radius: 3px 3px 0 0;
&::-webkit-input-placeholder {
color: #888;
}
}
.wmd-button-row {
position: relative;
margin: ($baseline/2) ($baseline/4) ($baseline/4) ($baseline/4);
padding: 0;
height: 30px;
overflow: hidden;
@include transition(all .2s ease-out 0s);
}
.wmd-spacer {
width: 1px;
height: 20px;
margin-left: 14px;
position: absolute;
background-color: Silver;
display: inline-block;
list-style: none;
}
.wmd-button {
width: 20px;
height: 20px;
padding-left: 2px;
padding-right: 3px;
position: absolute;
display: inline-block;
list-style: none;
cursor: pointer;
background: none;
}
.wmd-button > span {
display: inline-block;
background-image: url(../images/new-post-icons-full.png);
background-repeat: no-repeat;
background-position: 0px 0px;
width: 20px;
height: 20px;
}
.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 {
width: 20px;
height: 20px;
background-image: url("/static/images/wmd-buttons.png");
display: inline-block;
}
}
.wmd-spacer1 {
left: 50px;
}
.wmd-spacer2 {
left: 175px;
}
.wmd-spacer3 {
left: 300px;
}
.edit-post-form {
width: 100%;
margin-bottom: $baseline;
......@@ -1864,104 +977,13 @@ body.discussion {
}
.discussion-user-threads {
@extend .discussion-module
}
@extend .discussion-module;
// ====================
// post actions - pinning
.discussion-pin {
font-size: 12px;
float:right;
padding-right: 5px;
font-style: italic;
margin-right: $baseline/2;
opacity: 0.8;
&.admin-pin {
cursor: pointer;
&:hover, &:focus {
@include transition(opacity .2s linear 0s);
opacity: 1.0;
}
}
}
.discussion-pin-inline {
font-size: 12px;
float:right;
font-style: italic;
position: relative;
right:-20px;
top:-13px;
margin-right:35px;
margin-top:13px;
opacity: 1.0;
}
.notpinned .icon {
display: block;
float: left;
margin: 3px;
width: 10px;
height: 14px;
padding-right: 3px;
color: #333;
}
.pinned .icon {
display: block;
float: left;
margin: 3px;
width: 10px;
height: 14px;
padding-right: 3px;
color: $pink;
}
.pinned span {
color: $pink;
font-style: italic;
}
.notpinned span {
color: #333;
font-style: italic;
}
.pinned-false
{
display:none;
}
// ====================
// post actions - flagging
.discussion-flag-abuse, .discussion-delete-comment, .discussion-edit-comment {
font-size: 12px;
float:right;
margin-left: ($baseline/2);
font-style: italic;
cursor:pointer;
color: $dark-gray;
opacity: 0.8;
&:hover, &:focus {
@include transition(opacity .2s linear 0s);
opacity: 1.0;
}
.flag-label {
font-style: italic;
margin-left: ($baseline/4);
.discussion-post {
padding-bottom: $baseline !important;
}
}
.flagged * {
color: $pink;
}
// ====================
// post pagination
......
// 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 {
@include box-sizing(border-box);
float: left;
......@@ -124,21 +127,35 @@
background-color: $gray-l5;
padding: ($baseline/4) ($baseline/2);
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 {
border: none;
max-width: 100%;
background-color: transparent;
font: inherit;
}
.forum-nav-filter-cohort-control {
.forum-nav-filter-main-control {
@extend %forum-nav-select;
}
.forum-nav-sort {
float: right;
.forum-nav-filter-cohort-control {
@extend %forum-nav-select;
}
.forum-nav-sort-control {
......@@ -176,65 +193,41 @@
vertical-align: middle;
}
.forum-nav-thread-wrapper-1 {
@extend %forum-nav-thread-wrapper;
width: 70%;
}
.forum-nav-thread-wrapper-2 {
.forum-nav-thread-wrapper-0 {
@extend %forum-nav-thread-wrapper;
width: 30%;
text-align: right;
}
.forum-nav-thread-title {
@extend %t-title7;
display: block;
}
width: 7%;
%forum-nav-thread-label {
@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;
&:last-child {
margin-right: 0;
.icon {
@include font-size(14);
}
.icon {
margin-right: ($baseline/5);
.icon-comments {
color: $gray-l2;
}
}
.icon-ok {
color: $forum-color-marked-answer;
}
.forum-nav-thread-label-pinned {
@extend %forum-nav-thread-label;
border-color: $forum-color-pinned;
color: $forum-color-pinned;
.icon-question {
color: $pink;
}
}
.forum-nav-thread-label-following {
@extend %forum-nav-thread-label;
border-color: $forum-color-following;
color: $forum-color-following;
.forum-nav-thread-wrapper-1 {
@extend %forum-nav-thread-wrapper;
width: 80%;
}
.forum-nav-thread-label-staff {
@extend %forum-nav-thread-label;
border-color: $forum-color-staff;
color: $forum-color-staff;
.forum-nav-thread-wrapper-2 {
@extend %forum-nav-thread-wrapper;
width: 13%;
text-align: right;
}
.forum-nav-thread-label-community-ta {
@extend %forum-nav-thread-label;
border-color: $forum-color-community-ta;
color: $forum-color-community-ta;
.forum-nav-thread-title {
@extend %t-title7;
display: block;
}
%forum-nav-thread-wrapper-2-content {
......@@ -249,11 +242,6 @@
}
}
.forum-nav-thread-endorsed {
@extend %forum-nav-thread-wrapper-2-content;
color: $green-d1;
}
.forum-nav-thread-votes-count {
@extend %forum-nav-thread-wrapper-2-content;
}
......
......@@ -66,9 +66,16 @@
// navigation - sort and filter bar
// --------------------------------
// Override global span rules
.forum-nav-sort-label {
color: inherit;
// Override global label rules
.forum-nav-filter-main, .forum-nav-filter-cohort, .forum-nav-sort {
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-] {
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-pinned: $pink;
$forum-color-reported: $pink;
$forum-color-closed: $black;
$forum-color-following: $blue;
$forum-color-staff: $blue;
$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;
}
}
}
.forum-response .action-show-comments {
@include box-sizing(border-box);
@include font-size(13);
display: block;
padding: ($baseline/2) $baseline;
width: 100%;
background: $gray-l6;
box-shadow: 0 1px 3px -1px $shadow inset;
}
// discussion - thread layout
// ====================
// general thread layout
body.discussion, .discussion-module {
// post layout
.discussion-post {
padding: ($baseline*2) ($baseline*2) $baseline ($baseline*2);
border-radius: 3px 3px 0 0;
background-color: $white;
.post-header-content {
display: inline-block;
width: flex-grid(9,12);
}
.post-header-actions {
display: inline-block;
float: right;
vertical-align: middle;
width: flex-grid(3,12);
}
}
// response layout
.discussion-response {
min-height: ($baseline*7.5);
.username {
@include font-size(14);
@extend %t-weight5;
}
.response-header-content {
display: inline-block;
vertical-align: top;
width: flex-grid(9,12);
}
.response-header-actions {
width: flex-grid(3,12);
float: right;
}
}
// comments layout
.comments {
@extend %ui-no-list;
border-radius: 0 0 3px 3px;
background: $gray-l6;
box-shadow: 0 1px 3px -1px $shadow inset;
> li {
border-top: 1px solid $gray-l4;
padding: ($baseline/2) $baseline;
}
blockquote {
background: $gray-l4;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
font-size: 14px;
}
.comment-form {
@include clearfix;
.comment-form-input {
padding: ($baseline/4) ($baseline/2);
background-color: $white;
font-size: 14px;
}
.discussion-submit-comment {
@include blue-button;
float: left;
margin-top: 8px;
}
.wmd-input {
height: 40px;
}
.discussion-errors {
margin: 0;
}
}
.response-body {
display: inline-block;
margin-bottom: ($baseline/2);
width: flex-grid(10,12);
font-size: 13px;
p + p {
margin-top: 12px;
}
}
.comment-actions-list {
display: inline-block;
width: flex-grid(2,12);
vertical-align: top;
}
//TO-DO : clean up posted-details styling, currently reused by responses and comments
.posted-details {
margin-top: 0;
}
}
}
.forum-thread-main-wrapper {
border-bottom: 1px solid $white; // Prevent collapsing margins
border-radius: 3px 3px 0 0;
background-color: $white;
}
body.discussion, .discussion-thread.expanded {
.forum-thread-main-wrapper {
box-shadow: 0 1px 3px $shadow;
}
}
......@@ -14,7 +14,7 @@
<%def name="render_entry(entries, entry)">
<li
class="forum-nav-browse-menu-item"
data-discussion-id='${json.dumps(entries[entry])}'
data-discussion-id='${entries[entry]["id"]}'
data-cohorted="${str(entries[entry]['is_cohorted']).lower()}"
>
<a href="#" class="forum-nav-browse-title">${entry}</a>
......@@ -42,11 +42,6 @@
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">${_("All Discussions")}</a>
</li>
%if flag_moderator:
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-flagged">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-flag"></i>${_("Flagged Discussions")}</a>
</li>
%endif
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-following">
<a href="#" class="forum-nav-browse-title"><i class="icon icon-star"></i>${_("Posts I'm Following")}</a>
</li>
......
<%! from urllib import urlencode %>
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def url_for_page(_page):
return base_url + '?' + urlencode(merge(query_params, {'page': _page}))
%>
<%def name="link_to_page(_page, text)">
<a class="discussion-page-link" href="javascript:void(0)" page-url="${url_for_page(_page) | h}">${text}</a>
</%def>
<%def name="div_page(_page)">
% if _page != page:
<div class="page-link">
${link_to_page(_page, str(_page))}
</div>
% else:
<div class="page-link">${_page}</div>
% endif
</%def>
<%def name="list_pages(*args)">
% for arg in args:
% if arg == 'dots':
<div class="page-dots">...</div>
% elif isinstance(arg, list):
% for _page in arg:
${div_page(_page)}
% endfor
% else:
${div_page(arg)}
% endif
% endfor
</%def>
<div class="discussion-${discussion_type | h}-paginator discussion-paginator local">
<div class="prev-page">
% if page > 1:
${link_to_page(page - 1, "&lt; Previous page")}
% endif
</div>
% if num_pages <= 2 * pages_nearby_delta + 2:
${list_pages(range(1, num_pages + 1))}
% else:
% if page <= 2 * pages_nearby_delta:
${list_pages(range(1, 2 * pages_nearby_delta + 2), 'dots', num_pages)}
% elif num_pages - page + 1 <= 2 * pages_nearby_delta:
${list_pages(1, 'dots', range(num_pages - 2 * pages_nearby_delta, num_pages + 1))}
% else:
${list_pages(1, 'dots', range(page - pages_nearby_delta, page + pages_nearby_delta + 1), 'dots', num_pages)}
% endif
% endif
<div class="next-page">
% if page < num_pages:
${link_to_page(page + 1, "Next page &gt;")}
% endif
</div>
</div>
<%! from urllib import urlencode %>
<%def name="link_to_sort(key, title)">
% if key == sort_key:
${_link_to_sort(key, None, title + '', 'sorted')}
<!---
% if sort_order.lower() == 'desc':
${_link_to_sort(key, 'asc', title + '', 'sorted')}
% else:
${_link_to_sort(key, 'desc', title + '', 'sorted')}
% endif
-->
% else:
${_link_to_sort(key, 'desc', title)}
% endif
</%def>
<%def name="_link_to_sort(key, order, title, cls='')">
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def url_for_sort(key, order):
if order is None:
return ''
else:
return base_url + '?' + urlencode(merge(query_params, {'page': 1, 'sort_key': key, 'sort_order': order}))
%>
<a class="discussion-sort-link ${cls | h}" href="javascript:void(0)" sort-url="${url_for_sort(key, order) | h}">${title}</a>
</%def>
<div class="discussion-sort local">
<span class="discussion-label">Sort by:</span>
${link_to_sort('date', 'date')}
${link_to_sort('activity', 'top')}
${link_to_sort('votes', 'votes')}
${link_to_sort('comments', 'comments')}
</div>
......@@ -20,18 +20,41 @@
<%include file="_filter_dropdown.html" />
<div class="forum-nav-thread-list-wrapper">
<div class="forum-nav-refine-bar">
<label class="forum-nav-filter-main">
## Translators: This labels a filter menu in forum navigation
<span class="sr">${_("Filter:")}</span>
<select class="forum-nav-filter-main-control">
## Translators: This is a menu option for showing all forum threads unfiltered
<option value="all">${_("Show all")}</option>
## Translators: This is a menu option for showing only unread forum threads
<option value="unread">${_("Unread")}</option>
## Translators: This is a menu option for showing only unanswered forum
## question threads
<option value="unanswered">${_("Unanswered")}</option>
%if flag_moderator:
## Translators: This is a menu option for showing only forum threads flagged
## for abuse
<option value="flagged">${_("Flagged")}</option>
%endif
</select>
</label>\
%if is_course_cohorted and is_moderator:
<span class="forum-nav-filter-cohort">
## Lack of indentation is intentional to avoid whitespace between this and siblings
<label class="forum-nav-filter-cohort">
## Translators: This labels a cohort menu in forum navigation
<span class="sr">${_("Cohort:")}</span>
<select class="forum-nav-filter-cohort-control">
<option value="all">${_("View all cohorts")}</option>
<option value="all">${_("in all cohorts")}</option>
%for c in cohorts:
<option value="${c['id']}">${_("View as {cohort_name}").format(cohort_name=c['name'])}</option>
<option value="${c['id']}">${c['name']}</option>
%endfor
</select>
</span>
</label>\
%endif
<span class="forum-nav-sort">
## Lack of indentation is intentional to avoid whitespace between this and siblings
<label class="forum-nav-sort">
## Translators: This labels a sort menu in forum navigation
<span class="sr">${_("Sort:")}</span>
<select class="forum-nav-sort-control">
## Translators: This is a menu option for sorting forum threads
<option value="date">${_("by recent activity")}</option>
......@@ -40,7 +63,7 @@
## Translators: This is a menu option for sorting forum threads
<option value="votes">${_("by most votes")}</option>
</select>
</span>
</label>
</div>
<div class="search-alerts"></div>
<ul class="forum-nav-thread-list"></ul>
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.template.defaultfilters import escapejs %>
<%! from django_comment_client.permissions import has_permission %>
## IMPORTANT: In order to keep js tests valid and relevant, please be sure to update the appropriate HTML in
## common/static/coffee/spec/discussion_spec_helper.coffee is changed and regenerated, whenever this one changes.
<script aria-hidden="true" type="text/template" id="thread-template">
<article class="discussion-article" data-id="${'<%- id %>'}">
<div class="thread-wrapper">
<div class="forum-thread-main-wrapper">
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<ol class="responses js-marked-answer-list"></ol>
</div>
</div>
<div class="post-extended-content">
<div class="response-count"/>
<div class="add-response">
<button class="button add-response-btn">
......@@ -12,7 +20,7 @@
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
</div>
<ol class="responses"/>
<ol class="responses js-response-list"/>
<div class="response-pagination"/>
<div class="post-status-closed bottom-post-status" style="display: none">
${_("This thread is closed.")}
......@@ -27,6 +35,12 @@
</div>
</form>
% endif
</div>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="forum-thread-expand"><span class="icon icon-plus"/> ${_("Expand discussion")}</a>
<a href="javascript:void(0)" class="forum-thread-collapse"><span class="icon icon-minus"/> ${_("Collapse discussion")}</a>
</div>
</article>
</script>
......@@ -37,40 +51,43 @@
<div class="group-visibility-label">${"<%- obj.group_string%>"}</div>
${"<% } %>"}
<a href="#" class="vote-btn" role="button" aria-pressed="false"><!-- DiscussionContentView.renderVote populates this --></a>
<div class="post-header-content">
<h1>${'<%- title %>'}</h1>
<p class="posted-details">
${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="username">${'<%- username %>'}</a>
${"<% } else { %>"}
${_('anonymous') | h}
${"<% } %>"}
<span class="timeago" title="${'<%- created_at %>'}">${'<%- created_at %>'}</span>
<span class="post-status-closed top-post-status" style="display: none">
${_("&bull; This thread is closed.")}
</span>
## This part is incredibly gross but necessary to combine i18n in
## mako with logic in underscore.
## Translators: post_type describes the kind of post this is
## (e.g. "question" or "discussion"); time_ago is how much time
## has passed since the post was created (e.g. "4 hours ago")
${_("{post_type} posted {time_ago} by {author}").format(
post_type="<%- thread_type %>",
time_ago="<span class='timeago' title='<%- created_at %>'><%- created_at %></span>",
author="<%= author_display %>"
)}
</p>
<a href="javascript:void(0)" class="dogear action-follow" data-tooltip="${_('follow') | h}" role="checkbox" aria-checked="false">
<span class="sr">${_("Follow this post")}</span>
</a>
<div class="post-labels">
<span class="post-label-pinned"><i class="icon icon-pushpin"></i>${_("Pinned")}</span>
<span class="post-label-reported"><i class="icon icon-flag"></i>${_("Reported")}</span>
<span class="post-label-closed"><i class="icon icon-lock"></i>${_("Closed")}</span>
</div>
</div>
<div class="post-header-actions post-extended-content">
${"""<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'post',
primaryActions: ['vote', 'follow'],
secondaryActions: ['pin', 'edit', 'delete', 'report', 'close']
}
)
%>"""}
</div>
</header>
<div class="post-body">${'<%- body %>'}</div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-flag"></i><span class="flag-label">${_("Report Misuse")}</span></div>
% if course and has_permission(user, 'openclose_thread', course.id):
<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>
%else:
${"<% if (pinned) { %>"}
<div class="discussion-pin notpinned">
<i class="icon icon-pushpin"></i><span class="pin-label">${_("Pinned")}</span></div>
${"<% } %>"}
% endif
<% js_block = u"""
var courseware_link = interpolate('<a href="%s">%s</a>', [courseware_url, _.escape(courseware_title)]);
......@@ -80,15 +97,9 @@
escapejs(_("(this post is about %(courseware_title_linked)s)"))
)
%>
${'<% if (obj.courseware_url) { %>'}
${'<% if (mode == "tab" && obj.courseware_url) { %>'}
<div class="post-context">${'<%'}${js_block}${'%>'}</div>
${'<% } %>'}
<ul class="moderator-actions">
<li style="display: none"><a class="action-edit" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Edit")}</a></li>
<li style="display: none"><a class="action-delete" href="javascript:void(0)"><span class="delete-icon"></span> ${_("Delete")}</a></li>
<li style="display: none"><a class="action-openclose" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Close")}</a></li>
</ul>
</div>
</script>
......@@ -110,8 +121,12 @@
<script aria-hidden="true" type="text/template" id="thread-response-template">
<div class="discussion-response"></div>
<a href="#" class="action-show-comments">
${u"<%- interpolate('{}', {{num_comments: comments.length}}, true) %>".format(escapejs(_("Show Comments (%(num_comments)s)")))}
<i class="icon icon-caret-down"></i>
</a>
<ol class="comments">
<li class="new-comment response-local">
<li class="new-comment">
% if course is UNDEFINED or has_permission(user, 'create_sub_comment', course.id):
<form class="comment-form" data-id="${'<%- wmdId %>'}">
<ul class="discussion-errors"></ul>
......@@ -128,25 +143,59 @@
</script>
<script aria-hidden="true" type="text/template" id="thread-response-show-template">
<header class="response-local">
<a href="#" class="vote-btn" role="button" aria-pressed="false"><!-- DiscussionContentView.renderVote() populates this --></a>
<a href="javascript:void(0)" class="endorse-btn action-endorse" style="cursor: default; display: none;" data-tooltip="${_('endorse') | h}"><span class="check-icon" style="pointer-events: none; "></span></a>
${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="posted-by">${'<%- username %>'}</a>
${"<% } else { %>"}
<span class="anonymous"><em>${_('anonymous')}</em></span>
${"<% } %>"}
<p class="posted-details" title="${'<%- created_at %>'}">${'<%- created_at %>'}</p>
<header>
<div class="response-header-content">
${'<%= author_display %>'}
<p class="posted-details">
<span class="timeago" title="${'<%= created_at %>'}">${'<%= created_at %>'}</span>
<%
js_block = u"""
interpolate(
thread.get("thread_type") == "question" ?
(endorsement.username ? "{question_user_fmt_str}" : "{question_anon_fmt_str}") :
(endorsement.username ? "{discussion_user_fmt_str}" : "{discussion_anon_fmt_str}"),
{{
'time_ago': '<span class="timeago" title="' + endorsement.time + '">' + endorsement.time + '</span>',
'user': endorser_display
}},
true
)""".format(
## Translators: time_ago is a placeholder for a fuzzy, relative timestamp
## like "4 hours ago" or "about a month ago"
question_user_fmt_str=escapejs(_("marked as answer %(time_ago)s by %(user)s")),
## Translators: time_ago is a placeholder for a fuzzy, relative timestamp
## like "4 hours ago" or "about a month ago"
question_anon_fmt_str=escapejs(_("marked as answer %(time_ago)s")),
## Translators: time_ago is a placeholder for a fuzzy, relative timestamp
## like "4 hours ago" or "about a month ago"
discussion_user_fmt_str=escapejs(_("endorsed %(time_ago)s by %(user)s")),
## Translators: time_ago is a placeholder for a fuzzy, relative timestamp
## like "4 hours ago" or "about a month ago"
discussion_anon_fmt_str=escapejs(_("endorsed %(time_ago)s")),
)
%>
${"<% if (obj.endorsement) { %> - <%="}${js_block}${"%><% } %>"}
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>${_("Reported")}</span>
</div>
</div>
<div class="response-header-actions">
${"""<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'response',
primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>"""}
</div>
</header>
<div class="response-local"><div class="response-body">${"<%- body %>"}</div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-flag"></i><span class="flag-label">${_("Report Misuse")}</span></div>
</div>
<ul class="moderator-actions response-local">
<li style="display: none"><a class="action-edit" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Edit")}</a></li>
<li style="display: none"><a class="action-delete" href="javascript:void(0)"><span class="delete-icon"></span> ${_("Delete")}</a></li>
<li style="display: none"><a class="action-openclose" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Close")}</a></li>
</ul>
<div class="response-body">${"<%- body %>"}</div>
</script>
<script aria-hidden="true" type="text/template" id="thread-response-edit-template">
......@@ -164,31 +213,34 @@
<script aria-hidden="true" type="text/template" id="response-comment-show-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') | h}" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-flag"></i><span class="sr flag-label">${_("Report Misuse")}</span></div>
<div style="display: none" class="discussion-delete-comment action-delete" data-tooltip="${_('Delete Comment') | h}" role="button" tabindex="0">
<i class="icon icon-remove"></i><span class="sr">${_("Delete Comment")}</span></div>
<div class="discussion-edit-comment action-edit" data-tooltip="${_('Edit') | h}" role="button" tabindex="0">
<i class="icon icon-pencil"></i><span class="sr">${_("Edit")}</span></div>
${"""<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'comment',
primaryActions: [],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>"""}
<%
js_block = u"""
interpolate(
'{}',
{{'time_ago': '<span class=\"timeago\" title=\"' + created_at + '\">' + created_at + '</span>'}},
{{'time_ago': '<span class=\"timeago\" title=\"' + created_at + '\">' + created_at + '</span>', 'author': author_display}},
true
)""".format(
## Translators: 'timeago' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago)
escapejs(_('-posted %(time_ago)s by'))
escapejs(_('posted %(time_ago)s by %(author)s'))
)
%>
<p class="posted-details">
${'<%='}${js_block}${'%>'}
${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="profile-link">${'<%- username %>'}</a>
${"<% } else { %>"}
${_('anonymous')}
${"<% } %>"}
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon icon-flag"></i>${_("Reported")}</span>
</div>
</div>
</script>
......@@ -207,22 +259,45 @@
<script aria-hidden="true" type="text/template" id="thread-list-item-template">
<li data-id="${'<%- id %>'}" class="forum-nav-thread${'<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>'}">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-1">
<div class="forum-nav-thread-wrapper-0">
${u"""<%
var icon_class, sr_text;
if (thread_type == "discussion") {{
icon_class = "icon-comments";
sr_text = "{discussion}";
}} else if (endorsed) {{
icon_class = "icon-ok";
sr_text = "{answered_question}";
}} else {{
icon_class = "icon-question";
sr_text = "{unanswered_question}";
}}
%>""".format(
## Translators: This is a label for a Discussion forum thread
discussion=escapejs(_("discussion")),
## Translators: This is a label for a Question forum thread with a marked answer
answered_question=escapejs(_("answered question")),
## Translators: This is a label for a Question forum thread without a marked answer
unanswered_question=escapejs(_("unanswered question"))
)}
<span class="sr">${"<%= sr_text %>"}</span>
<i class="icon ${"<%= icon_class %>"}"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title">${"<%- title %>"}</span>
<%
js_block = u"""
var labels = "";
if (pinned) {{
labels += '<li class="forum-nav-thread-label-pinned"><i class="icon icon-pushpin"></i>{pinned_text}</li> ';
labels += '<li class="post-label-pinned"><i class="icon icon-pushpin"></i>{pinned_text}</li> ';
}}
if (typeof(subscribed) != "undefined" && subscribed) {{
labels += '<li class="forum-nav-thread-label-following"><i class="icon icon-star"></i>{following_text}</li> ';
labels += '<li class="post-label-following"><i class="icon icon-star"></i>{following_text}</li> ';
}}
if (staff_authored) {{
labels += '<li class="forum-nav-thread-label-staff"><i class="icon icon-user"></i>{staff_text}</li> ';
labels += '<li class="post-label-by-staff"><i class="icon icon-user"></i>{staff_text}</li> ';
}}
if (community_ta_authored) {{
labels += '<li class="forum-nav-thread-label-community-ta"><i class="icon icon-user"></i>{community_ta_text}</li> ';
labels += '<li class="post-label-by-community-ta"><i class="icon icon-user"></i>{community_ta_text}</li> ';
}}
if (labels != "") {{
print('<ul class="forum-nav-thread-labels">' + labels + '</ul>');
......@@ -240,10 +315,6 @@
%>
${"<%"}${js_block}${"%>"}
</div><div class="forum-nav-thread-wrapper-2">
${"<% if (endorsed) { %>"}
## Translators: This is a label for a forum thread with a response that was endorsed by the course staff
<span class="forum-nav-thread-endorsed"><i class="icon icon-ok"></i><span class="sr">${_("Endorsed response")}</span></span>
${"<% } %>"}
<%
js_block = u"""
interpolate(
......@@ -345,7 +416,6 @@
</tr>
</table>
% endif
</div>
</script>
......@@ -361,107 +431,222 @@
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-tab-template">
<div class="inner-wrapper">
<form class="new-post-form">
<div class="left-column">
<!-- BEGIN topic dropdown -->
${'<%= topic_dropdown_html %>'}
<!-- END topic dropdown -->
<!-- BEGIN options -->
${'<%= options_html %>'}
<!-- END options -->
</div>
<div class="right-column">
<!-- BEGIN errors display -->
<ul class="new-post-form-errors"></ul>
<!-- END errors display -->
<!-- BEGIN content editor -->
${'<%= editor_html %>'}
<!-- END content editor -->
</div>
</form>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-inline-template">
<div class="inner-wrapper">
<!-- BEGIN errors display -->
<div class="new-post-form-errors">
</div>
<!-- END errors display -->
<form class="new-post-form">
<!-- BEGIN content editor -->
${'<%= editor_html %>'}
<!-- END content editor -->
<!-- BEGIN options -->
${'<%= options_html %>'}
<!-- END options -->
</form>
<script aria-hidden="true" type="text/template" id="new-post-template">
<form class="forum-new-post-form">
<ul class="post-errors" style="display: none"></ul>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
## Translators: This is the label for a control to
## select a forum post type
${_("Post type:")}
</span><fieldset class="field-input">
<input type="radio" name="${"<%= form_id %>"}-post-type" class="post-type-input" id="${"<%= form_id %>"}-post-type-question" value="question" checked>
<label for="${"<%= form_id %>"}-post-type-question" class="post-type-label">
<i class="icon icon-question"></i>
## Translators: This is a forum post type
${_("Question")}
</label>
<input type="radio" name="${"<%= form_id %>"}-post-type" class="post-type-input" id="${"<%= form_id %>"}-post-type-discussion" value="discussion">
<label for="${"<%= form_id %>"}-post-type-discussion" class="post-type-label">
<i class="icon icon-comments"></i>
## Translators: This is a forum post type
${_("Discussion")}
</label>
</fieldset>
</div><span class="field-help">
${_("Questions raise issues that need answers. Discussions share ideas and start conversations.")}
</span>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem"><a href="#" class="topic" data-discussion_id="${'<%- id %>'}" aria-describedby="topic-name-span-${'<%- id %>'}" cohorted="${'<%- is_cohorted %>'}">${'<%- text %>'}</a></li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem">
<a href="#"><span class="category-menu-span">${'<%- text %>'}</span></a>
<ul role="menu">${'<%= entries %>'}</ul>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-topic-dropdown-template">
<span class="topic-dropdown-label" id="topic-dropdown-label">${_("Create new post about:")}</span>
<div class="form-topic-drop">
<a href="#" aria-labelledby="topic-dropdown-label" class="topic_dropdown_button">${_("Show All Discussions")}<span class="drop-arrow" aria-hidden="true"></span></a>
<div class="topic_menu_wrapper">
<div class="topic_menu_search" role="menu">
<label class="sr" for="browse-topic-newpost">${_("Filter List")}</label>
<input type="text" id="browse-topic-newpost" class="form-topic-drop-search-input" placeholder="${_('Filter discussion areas')}">
${'<% if (mode=="tab") { %>'}
<div class="post-field">
## Using div here instead of label because we are using a non-native control
<div class="field-label">
<span class="field-label-text">
${_("Topic Area:")}
</span><div class="field-input post-topic">
<a href="#" class="post-topic-button">
<span class="sr">${_("Discussion topics; current selection is: ")}</span>
<span class="js-selected-topic"></span>
<span class="drop-arrow" aria-hidden="true"></span>
</a>
<div class="topic-menu-wrapper">
<label class="topic-filter-label">
<span class="sr">${_("Filter topics")}</span>
<input type="text" class="topic-filter-input" placeholder="${_('Filter topics')}">
</label>
<ul class="topic-menu" role="menu">${'<%= topics_html %>'}</ul>
</div>
<ul class="topic_menu" role="menu">${'<%= topics_html %>'}</ul>
</div>
</div><span class="field-help">
${_("Add your post to a relevant topic to help others find it.")}
</span>
</div>
</script>
<script aria-hidden="true" type="text/template" id="new-post-options-template">
<div class="options">
<input type="checkbox" name="follow" class="discussion-follow" id="new-post-follow" checked><label for="new-post-follow">${_("follow this post")}</label>
${'<% if (allow_anonymous) { %>'}
<br>
<input type="checkbox" name="anonymous" class="discussion-anonymous" id="new-post-anonymous">
<label for="new-post-anonymous">${_("post anonymously")}</label>
${'<% } %>'}
${'<% if (allow_anonymous_to_peers) { %>'}
<br>
<input type="checkbox" name="anonymous_to_peers" class="discussion-anonymous-to-peers" id="new-post-anonymous-to-peers">
<label for="new-post-anonymous-to-peers">${_("post anonymously to classmates")}</label>
${'<% } %>'}
${'<% if (cohort_options) { %>'}
<div class="form-group-label choose-cohort">
<div class="post-field">
<label class="field-label">
<span class="field-label-text">
## Translators: This labels the selector for which group of students can view a post
${_("Make visible to:")}
<select class="group-filter-select new-post-group" name="group_id">
${_("Visible To:")}
</span><select class="field-input js-group-select" name="group_id">
<option value="">${_("All Groups")}</option>
${'<% _.each(cohort_options, function(opt) { %>'}
<option value="${'<%= opt.value %>'}" ${'<% if (opt.selected) { %>selected<% } %>'}>${'<%- opt.text %>'}</option>
${'<% }); %>'}
</select>
</label><div class="field-help">
${_("Instructors can set whether a post in a cohorted topic is visible to all cohorts or only to a specific cohort.")}
</div>
</div>
${'<% } %>'}
<div class="post-field">
<label class="field-label">
<span class="sr">${_("Title:")}</span>
<input type="text" class="field-input js-post-title" name="title" placeholder="${_('Title')}">
</label><span class="field-help">
${_("Add a clear and descriptive title to encourage participation.")}
</span>
</div>
<div class="post-field js-post-body editor" name="body" data-placeholder="${_(u'Enter your question or comment…')}"></div>
<div class="post-options">
<label class="post-option is-enabled">
<input type="checkbox" name="follow" class="post-option-input js-follow" checked>
<i class="icon icon-star"></i>${_("follow this post")}
</label>
${'<% if (allow_anonymous) { %>'}
<label class="post-option">
<input type="checkbox" name="anonymous" class="post-option-input js-anon">
${_("post anonymously")}
</label>
${'<% } %>'}
${'<% if (allow_anonymous_to_peers) { %>'}
<label class="post-option">
<input type="checkbox" name="anonymous_to_peers" class="post-option-input js-anon-peers">
${_("post anonymously to classmates")}
</label>
${'<% } %>'}
</div>
<div>
<input type="submit" class="submit" value="${_('Add Post')}">
<a href="#" class="cancel">${_('Cancel')}</a>
</div>
</form>
</script>
<script aria-hidden="true" type="text/template" id="new-post-editor-template">
<div class="form-row">
<label class="sr" for="new-post-title">${_("new post title")}</label>
<input type="text" id="new-post-title" class="new-post-title" name="title" placeholder="${_('Title')}">
<script aria-hidden="true" type="text/template" id="new-post-menu-entry-template">
<li role="menuitem" class="topic-menu-item">
<a href="#" class="topic-title" data-discussion-id="${'<%- id %>'}" data-cohorted="${'<%- is_cohorted %>'}">${'<%- text %>'}</a>
</li>
</script>
<script aria-hidden="true" type="text/template" id="new-post-menu-category-template">
<li role="menuitem" class="topic-menu-item">
<span class="topic-title">${'<%- text %>'}</span>
<ul role="menu" class="topic-submenu">${'<%= entries %>'}</ul>
</li>
</script>
<%def name="primaryAction(action_class, icon, sr_label, unchecked_label, checked_label)">
<script type="text/template" id="forum-action-${action_class}">
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-${action_class}" role="checkbox" aria-checked="false">
<span class="sr">${sr_label}</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">${unchecked_label}</span>
<span class="label-checked">${checked_label}</span>
</span>
<span class="action-icon"><i class="icon icon-${icon}"></i></span>
</a>
</li>
</script>
</%def>
${primaryAction("endorse", "ok", _("Endorse"), _("Endorse"), _("Unendorse"))}
${primaryAction("answer", "ok", _("Mark as Answer"), _("Mark as Answer"), _("Unmark as Answer"))}
${primaryAction("follow", "star", _("Follow"), _("Follow"), _("Unfollow"))}
<script type="text/template" id="forum-action-vote">
<li class="actions-item">
<a href="#" class="action-button action-vote" role="checkbox" aria-checked="false">
## Vote counts are populated by JS
<span class="sr">${_("Vote")}</span>
<span class="sr js-sr-vote-count"></span>
<span class="action-label" aria-hidden="true">
<span class="js-visual-vote-count"></span>
</span>
<span class="action-icon" aria-hidden="true">
<i class="icon icon-plus"></i>
</span>
</a>
</li>
</script>
<%def name="secondaryStateAction(action_class, icon, sr_label, unchecked_label, checked_label)">
<script type="text/template" id="forum-action-${action_class}">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-${action_class}" role="checkbox" aria-checked="false">
<span class="sr">${sr_label}</span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked">${unchecked_label}</span>
<span class="label-checked">${checked_label}</span>
</span>
<span class="action-icon">
<i class="icon icon-${icon}"></i>
</span>
</a>
</li>
</script>
</%def>
${secondaryStateAction("report", "flag", _("Report abuse"), _("Report"), _("Unreport"))}
${secondaryStateAction("pin", "pushpin", _("Pin"), _("Pin"), _("Unpin"))}
${secondaryStateAction("close", "lock", _("Close"), _("Close"), _("Open"))}
<%def name="secondaryAction(action_class, icon, label)">
<script type="text/template" id="forum-action-${action_class}">
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-${action_class}" role="button">
<span class="action-label">${label}</span>
<span class="action-icon"><i class="icon icon-${icon}"></i></span>
</a>
</li>
</script>
</%def>
${secondaryAction("edit", "pencil", _("Edit"))}
${secondaryAction("delete", "remove", _("Delete"))}
<script type="text/template" id="forum-actions">
<ul class="${"<%= contentType %>"}-actions-list">
${"<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>"}
<li class="actions-item is-visible">
<div class="more-wrapper">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-${"<%= contentId %>"}">
<span class="action-label">${_("More")}</span>
<span class="action-icon"><i class="icon icon-ellipsis-horizontal"></i></span>
</a>
<div class="actions-dropdown" id="action-menu-${"<%= contentType %>"}" aria-expanded="false">
<ul class="actions-dropdown-list">
${"<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>"}
</ul>
</div>
<div class="form-row">
<div class="new-post-body" name="body" placeholder="${_(u'Enter your question or comment…')}"></div>
</div>
<input type="submit" id="new-post-submit" class="submit" value="${_('Add post')}">
<a href="#" class="new-post-cancel">${_('Cancel')}</a>
</li>
</ul>
</script>
<script aria-hidden="true" type="text/template" id="post-user-display-template">
${"<% if (username) { %>"}
<a href="${'<%- user_url %>'}" class="username">${'<%- username %>'}</a>
${"<% if (is_community_ta) { %>"}
<span class="user-label-community-ta">${_("Community TA")}</span>
${"<% } else if (is_staff) { %>"}
<span class="user-label-staff">${_("Staff")}</span>
${"<% } %>"}
${"<% } else { %>"}
${_('anonymous') | h}
${"<% } %>"}
</script>
......@@ -23,8 +23,6 @@
<%include file="_discussion_course_navigation.html" args="active_page='discussion'" />
<article class="new-post-article"></article>
<section class="discussion container" id="discussion-container"
data-roles="${roles}"
data-course-id="${course_id}"
......@@ -39,6 +37,8 @@
<div class="discussion-body">
<div class="forum-nav"></div>
<div class="discussion-column">
<article class="new-post-article" style="display: none"></article>
<div class="forum-content"></div>
</div>
</div>
</section>
......
<%! from django.utils.translation import ugettext as _ %>
<article class="discussion-article" data-id="{{id}}">
<div class="thread-wrapper">
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<div class="response-count"/>
<div class="add-response">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
</div>
<ol class="responses"/>
<div class="response-pagination"/>
{{#ability.can_reply}}
<form class="local discussion-reply-new" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
{{/ability.can_reply}}
</div>
</div>
<div class="local post-tools">
<a href="javascript:void(0)" class="expand-post"><span class="icon icon-plus"/> ${_("Expand discussion")}</a>
<a href="javascript:void(0)" class="collapse-post"><span class="icon icon-minus"/> ${_("Collapse discussion")}</a>
</div>
</article>
<%! from django.utils.translation import ugettext as _ %>
<article class="discussion-article" data-id="{{id}}">
<div class="thread-wrapper">
<div class="group-visibility-label">{{group_string}}</div>
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<div class="response-count"/>
<div class="add-response">
<button class="button add-response-btn">
<i class="icon icon-reply"></i>
<span class="add-response-btn-text">${_('Add A Response')}</span>
</button>
</div>
<ol class="responses"/>
<div class="response-pagination"/>
<form class="local discussion-reply-new" data-id="{{id}}">
<h4>${_("Post a response:")}</h4>
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="{{id}}"></div>
<div class="reply-post-control">
<a class="discussion-submit-post control-button" href="#">${_("Submit")}</a>
</div>
</form>
</div>
</div>
<div class="local post-tools">
<a href="javascript:void(0)" class="expand-post"><span class="icon icon-plus"/> ${_("Expand discussion")}</a>
<a href="javascript:void(0)" class="collapse-post"><span class="icon icon-minus"/> ${_("Collapse discussion")}</a>
</div>
</article>
<%! from django.utils.translation import ugettext as _ %>
<div class="discussion-post local">
<div><a href="javascript:void(0)" class="dogear action-follow" data-tooltip="follow"></a></div>
<header>
<a href="#" class="vote-btn" role="button" aria-pressed="false"/>
<h3>{{title}}</h3>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag">
<i class="icon icon-flag"></i><span class="flag-label"/></div>
<div class="discussion-pin-inline pinned pinned-{{pinned}}" data-tooltip="${_("This thread has been pinned by course staff.")}">
<i class="icon icon-pushpin"></i><span class="pin-label">${_("Pinned")}</span></div>
<p class="posted-details">
{{#user}}
<a href="{{user_url}}" class="username">{{username}}</a>
{{/user}}
{{^user}}
${_("anonymous")}
{{/user}}
<span class="timeago" title="{{created_at}}">{{created_at}}</span>
<span class="post-status-closed top-post-status" style="display: none">
&bull; ${_("This thread is closed.")}
</span>
</p>
</header>
<div class="post-body">{{abbreviatedBody}}</div>
<ul class="moderator-actions post-extended-content">
<li style="display: none"><a class="action-edit" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Edit")}</a></li>
<li style="display: none"><a class="action-delete" href="javascript:void(0)"><span class="delete-icon"></span> ${_("Delete")}</a></li>
<li style="display: none"><a class="action-openclose" href="javascript:void(0)"><span class="edit-icon"></span> ${_("Close")}</a></li>
</ul>
</div>
<%! from django.utils.translation import ugettext as _ %>
<nav class="discussion-{{discussiontype}}-paginator discussion-paginator local">
<nav class="discussion-{{discussiontype}}-paginator discussion-paginator">
<ol>
{{#previous}}
<li><a class="discussion-pagination" href="{{url}}" data-page-number="{{number}}">&lt; ${_("Previous")}</a></li>
......
<%! from django.utils.translation import ugettext as _ %>
<article class="discussion-article" data-id="{{id}}">
<div class="discussion-post local">
<div class="discussion-post">
<header>
<h3>{{title}}</h3>
<p class="posted-details">
......@@ -20,7 +20,7 @@
</header>
<div class="post-body">{{{abbreviatedBody}}}</div>
</div>
<div class="local post-tools">
<div class="post-tools">
<a href="{{permalink}}">${_("View discussion")}</a>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment