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', -> ...@@ -41,11 +41,11 @@ describe 'All Content', ->
it 'can update info', -> it 'can update info', ->
@content.updateInfo { @content.updateInfo {
ability: 'can_endorse', ability: {'can_edit': true},
voted: true, voted: true,
subscribed: true subscribed: true
} }
expect(@content.get 'ability').toEqual 'can_endorse' expect(@content.get 'ability').toEqual {'can_edit': true}
expect(@content.get 'voted').toEqual true expect(@content.get 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true expect(@content.get 'subscribed').toEqual true
...@@ -77,3 +77,39 @@ describe 'All Content', -> ...@@ -77,3 +77,39 @@ describe 'All Content', ->
myComments = new Comments myComments = new Comments
myComments.add @comment1 myComments.add @comment1
expect(myComments.find('123')).toBe @comment1 expect(myComments.find('123')).toBe @comment1
it 'can be endorsed', ->
DiscussionUtil.loadRoles(
{"Moderator": [111], "Administrator": [222], "Community TA": [333]}
)
@discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99})
@discussionResponse = new Comment({id: 1, thread: @discussionThread})
@questionThread = new Thread({id: 1, thread_type: "question", user_id: 99})
@questionResponse = new Comment({id: 1, thread: @questionThread})
# mod
window.user = new DiscussionUser({id: 111})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# admin
window.user = new DiscussionUser({id: 222})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# TA
window.user = new DiscussionUser({id: 333})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# thread author
window.user = new DiscussionUser({id: 99})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# anyone else
window.user = new DiscussionUser({id: 999})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(false)
describe 'DiscussionUtil', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe "updateWithUndo", ->
it "calls through to safeAjax with correct params, and reverts the model in case of failure", ->
deferred = $.Deferred()
spyOn($, "ajax").andReturn(deferred)
spyOn(DiscussionUtil, "safeAjax").andCallThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# the ajax request should fire and the model should be updated
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar"}, "error message")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: "world", number: 42})
# the error message callback should be set up correctly
spyOn(DiscussionUtil, "discussionAlert")
DiscussionUtil.safeAjax.mostRecentCall.args[0].error()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalledWith("Sorry", "error message")
# if the ajax call ends in failure, the model state should be reverted
deferred.reject()
expect(model.attributes).toEqual({hello: false, number: 42})
describe "DiscussionContentView", -> describe "DiscussionContentView", ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
setFixtures( DiscussionSpecHelper.setUnderscoreFixtures()
"""
<div class="discussion-post">
<header>
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class='votes-count-number'>0</span> <span class="sr">votes (click to vote)</span></a>
<h1>Post Title</h1>
<p class="posted-details">
<a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a>
<span title="2013-05-08T17:34:07Z" class="timeago">less than a minute ago</span>
</p>
</header>
<div class="post-body"><p>Post body.</p></div>
<div data-tooltip="Report Misuse" data-role="thread-flag" class="discussion-flag-abuse notflagged">
<i class="icon"></i><span class="flag-label">Report Misuse</span></div>
<div data-tooltip="pin this thread" class="admin-pin discussion-pin notpinned">
<i class="icon"></i><span class="pin-label">Pin Thread</span></div>
</div>
"""
)
@threadData = { @threadData = {
id: '01234567', id: '01234567',
...@@ -35,7 +16,8 @@ describe "DiscussionContentView", -> ...@@ -35,7 +16,8 @@ describe "DiscussionContentView", ->
} }
@thread = new Thread(@threadData) @thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread }) @view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post')) @view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', -> it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist expect($('#jasmine-fixtures')).toExist
...@@ -59,15 +41,3 @@ describe "DiscussionContentView", -> ...@@ -59,15 +41,3 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array) @thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse() @thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual [] expect(@thread.get 'abuse_flaggers').toEqual []
it 'renders the vote button properly', ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it 'votes correctly', ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, false)
it 'unvotes correctly', ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, false)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
describe "DiscussionThreadInlineView", ->
beforeEach ->
setFixtures(
"""
<script type="text/template" id="_inline_thread">
<article class="discussion-article">
<div class="non-cohorted-indicator"/>
<div class="post-body"/>
<div class="post-extended-content">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="expand-post">Expand</a>
<a href="javascript:void(0)" class="collapse-post">Collapse</a>
</div>
</article>
</script>
<script type="text/template" id="_inline_thread_cohorted">
<article class="discussion-article">
<div class="cohorted-indicator"/>
<div class="post-body"/>
<div class="post-extended-content">
<div class="response-count"/>
<ol class="responses"/>
<div class="response-pagination"/>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="expand-post">Expand</a>
<a href="javascript:void(0)" class="collapse-post">Collapse</a>
</div>
</article>
</script>
<div class="thread-fixture"/>
"""
)
@threadData = {
id: "dummy",
body: "dummy body",
abuse_flaggers: [],
votes: {up_count: "42"}
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadInlineView({ model: @thread })
@view.setElement($(".thread-fixture"))
spyOn($, "ajax")
# Avoid unnecessary boilerplate
spyOn(@view.showView, "render")
spyOn(@view.showView, "convertMath")
spyOn(@view, "makeWmdEditor")
spyOn(DiscussionThreadView.prototype, "renderResponse")
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toEqual(1)
expect(content.is(":visible")).toEqual(visible)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".expand-post", not expanded)
assertContentVisible(view, ".collapse-post", expanded)
describe "render", ->
it "uses the cohorted template if cohorted", ->
@view.model.set({group_id: 1})
@view.render()
expect(@view.$el.find(".cohorted-indicator").length).toEqual(1)
it "uses the non-cohorted template if not cohorted", ->
@view.render()
expect(@view.$el.find(".non-cohorted-indicator").length).toEqual(1)
it "shows content that should be visible when collapsed", ->
@view.render()
assertExpandedContentVisible(@view, false)
it "does not render any responses by default", ->
@view.render()
expect($.ajax).not.toHaveBeenCalled()
expect(@view.$el.find(".responses li").length).toEqual(0)
describe "expand/collapse", ->
it "shows/hides appropriate content", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expandPost()
assertExpandedContentVisible(@view, true)
@view.collapsePost()
assertExpandedContentVisible(@view, false)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@thread.set("body", new Array(100).join("test "))
@view.abbreviateBody()
expect(@thread.get("body")).not.toEqual(@thread.get("abbreviatedBody"))
@view.render()
@view.expandPost()
expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("body"))
expect(@view.showView.convertMath).toHaveBeenCalled()
@view.showView.convertMath.reset()
@view.collapsePost()
expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("abbreviatedBody"))
expect(@view.showView.convertMath).toHaveBeenCalled()
class @DiscussionViewSpecHelper class @DiscussionViewSpecHelper
@expectVoteRendered = (view, voted) -> @makeThreadWithProps = (props) ->
button = view.$el.find(".vote-btn") # Minimal set of properties necessary for rendering
if voted thread = {
expect(button.hasClass("is-cast")).toBe(true) id: "dummy_id",
expect(button.attr("aria-pressed")).toEqual("true") thread_type: "discussion",
expect(button.attr("data-tooltip")).toEqual("remove vote") pinned: false,
expect(button.text()).toEqual("43 votes (click to remove your vote)") endorsed: false,
else votes: {up_count: '0'},
expect(button.hasClass("is-cast")).toBe(false) unread_comments_count: 0,
expect(button.attr("aria-pressed")).toEqual("false") comments_count: 0,
expect(button.attr("data-tooltip")).toEqual("vote") abuse_flaggers: [],
expect(button.text()).toEqual("42 votes (click to vote)") body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
}
$.extend(thread, props)
@expectVoteRendered = (view, model, user) ->
button = view.$el.find(".action-vote")
expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$")
@checkRenderVote = (view, model) -> @checkRenderVote = (view, model) ->
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model) window.user.vote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, true) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model) window.user.unvote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").andCallFake((params) => spyOn($, "ajax").andCallFake((params) =>
newModelData = {} expect(params.url.toString()).toEqual(expectedUrl)
$.extend(newModelData, modelData, {votes: {up_count: "43"}}) return deferred
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
) )
view.render()
view.vote() view.$el.find(".action-vote").trigger(event)
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() expect($.ajax).toHaveBeenCalled()
deferred.resolve()
@checkUnvote = (view, model, modelData, checkRendering) -> @checkUpvote = (view, model, user, event) ->
window.user.vote(model) expect(model.id in user.get('upvoted_ids')).toBe(false)
expect(window.user.voted(model)).toBe(true) initialVoteCount = model.get('votes').up_count
if checkRendering triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
DiscussionViewSpecHelper.expectVoteRendered(view, true) expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "42"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
)
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
@checkToggleVote = (view, model) ->
event = {preventDefault: ->}
spyOn(event, "preventDefault")
spyOn(view, "vote").andCallFake(() -> window.user.vote(model))
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model))
expect(window.user.voted(model)).toBe(false)
view.toggleVote(event)
expect(view.vote).toHaveBeenCalled()
expect(view.unvote).not.toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(1)
view.vote.reset() @checkUnvote = (view, model, user, event) ->
view.unvote.reset() user.vote(model)
expect(window.user.voted(model)).toBe(true) expect(model.id in user.get('upvoted_ids')).toBe(true)
view.toggleVote(event) initialVoteCount = model.get('votes').up_count
expect(view.vote).not.toHaveBeenCalled() triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(view.unvote).toHaveBeenCalled() expect(user.get('upvoted_ids')).toEqual([])
expect(event.preventDefault.callCount).toEqual(2) expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
@checkButtonEvents = (view, viewFunc, buttonSelector) -> @checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc) spy = spyOn(view, viewFunc)
...@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper ...@@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper
expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) -> @checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".vote-btn") @checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) -> @setNextResponseContent = (content) ->
$.ajax.andCallFake( $.ajax.andCallFake(
......
...@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', -> ...@@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in # set up the container for the response to go in
setFixtures """ DiscussionSpecHelper.setUnderscoreFixtures()
<ol class="responses"></ol>
<script id="response-comment-show-template" type="text/template">
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<div class="discussion-flag-abuse notflagged" data-role="thread-flag" data-tooltip="report misuse">
<i class="icon"></i><span class="flag-label"></span></div>
<div style="display:none" class="discussion-delete-comment action-delete" data-role="comment-delete" data-tooltip="Delete Comment" role="button" aria-pressed="false" tabindex="0">
<i class="icon icon-remove"></i><span class="sr delete-label">Delete Comment</span></div>
<div style="display:none" class="discussion-edit-comment action-edit" data-tooltip="Edit Comment" role="button" tabindex="0">
<i class="icon icon-pencil"></i><span class="sr">Edit Comment</span></div>
<p class="posted-details">&ndash;posted <span class="timeago" title="<%- created_at %>"><%- created_at %></span> by
<% if (obj.username) { %>
<a href="<%- user_url %>" class="profile-link"><%- username %></a>
<% } else {print('anonymous');} %>
</p>
</div>
</script>
"""
# set up a model for a new Comment # set up a model for a new Comment
@comment = new Comment { @comment = new Comment {
...@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', -> ...@@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', ->
beforeEach -> beforeEach ->
spyOn(@view, 'renderAttrs') spyOn(@view, 'renderAttrs')
spyOn(@view, 'markAsStaff')
it 'produces the correct HTML', ->
@view.render()
expect(@view.el.innerHTML).toContain('"discussion-flag-abuse notflagged"')
it 'can be flagged for abuse', -> it 'can be flagged for abuse', ->
@comment.flagAbuse() @comment.flagAbuse()
...@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', -> ...@@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', ->
@view.bind "comment:edit", triggerTarget @view.bind "comment:edit", triggerTarget
@view.edit() @view.edit()
expect(triggerTarget).toHaveBeenCalled() expect(triggerTarget).toHaveBeenCalled()
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
...@@ -10,19 +10,9 @@ describe 'ResponseCommentView', -> ...@@ -10,19 +10,9 @@ describe 'ResponseCommentView', ->
abuse_flaggers: ['123'] abuse_flaggers: ['123']
roles: ['Student'] roles: ['Student']
} }
setFixtures """ DiscussionSpecHelper.setUnderscoreFixtures()
<script id="response-comment-show-template" type="text/template">
<div id="response-comment-show-div"/> @view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
</script>
<script id="response-comment-edit-template" type="text/template">
<div id="response-comment-edit-div">
<div class="edit-comment-body"><textarea/></div>
<ul class="edit-comment-form-errors"/>
</div>
</script>
<div id="response-comment-fixture"/>
"""
@view = new ResponseCommentView({ model: @comment, el: $("#response-comment-fixture") })
spyOn(ResponseCommentShowView.prototype, "convertMath") spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor") spyOn(DiscussionUtil, "makeWmdEditor")
@view.render() @view.render()
...@@ -95,8 +85,7 @@ describe 'ResponseCommentView', -> ...@@ -95,8 +85,7 @@ describe 'ResponseCommentView', ->
expect(@view._delete).toHaveBeenCalled() expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", makeEventSpy() @view.showView.trigger "comment:edit", makeEventSpy()
expect(@view.edit).toHaveBeenCalled() expect(@view.edit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(1) expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
expect(@view.$("#response-comment-edit-div").length).toEqual(0)
describe 'renderEditView', -> describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', -> it 'renders the edit view, removes the show view, and registers event handlers', ->
...@@ -107,8 +96,7 @@ describe 'ResponseCommentView', -> ...@@ -107,8 +96,7 @@ describe 'ResponseCommentView', ->
expect(@view.update).toHaveBeenCalled() expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", makeEventSpy() @view.editView.trigger "comment:cancel_edit", makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled() expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$("#response-comment-show-div").length).toEqual(0) expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
expect(@view.$("#response-comment-edit-div").length).toEqual(1)
describe 'edit', -> describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', -> it 'triggers the appropriate event and switches to the edit view', ->
...@@ -135,6 +123,8 @@ describe 'ResponseCommentView', -> ...@@ -135,6 +123,8 @@ describe 'ResponseCommentView', ->
describe 'update', -> describe 'update', ->
beforeEach -> beforeEach ->
@updatedBody = "updated body" @updatedBody = "updated body"
# Markdown code creates the editor, so we simulate that here
@view.$el.find(".edit-comment-body").html($("<textarea></textarea>"))
@view.$el.find(".edit-comment-body textarea").val(@updatedBody) @view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit') spyOn(@view, 'cancelEdit')
spyOn($, "ajax").andCallFake( spyOn($, "ajax").andCallFake(
......
describe 'ThreadResponseView', -> describe 'ThreadResponseView', ->
beforeEach -> beforeEach ->
setFixtures """ DiscussionSpecHelper.setUpGlobals()
<script id="thread-response-template" type="text/template"> DiscussionSpecHelper.setUnderscoreFixtures()
<div/>
</script>
<div id="thread-response-fixture"/>
"""
@response = new Comment { @response = new Comment {
children: [{}, {}] children: [{}, {}]
} }
@view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")}) @view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render") spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render") spyOn(ResponseCommentView.prototype, "render")
describe 'renderComments', -> describe 'renderComments', ->
it 'hides "show comments" link if collapseComments is not set', ->
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [] }
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).not.toBeVisible()
expect(@view.$(".action-show-comments")).toBeVisible()
@view.$(".action-show-comments").click()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'populates commentViews and binds events', -> it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit # Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView() @view.createEditView()
......
...@@ -9,7 +9,6 @@ if Backbone? ...@@ -9,7 +9,6 @@ if Backbone?
actions: actions:
editable: '.admin-edit' editable: '.admin-edit'
can_reply: '.discussion-reply' can_reply: '.discussion-reply'
can_endorse: '.admin-endorse'
can_delete: '.admin-delete' can_delete: '.admin-delete'
can_openclose: '.admin-openclose' can_openclose: '.admin-openclose'
...@@ -21,6 +20,9 @@ if Backbone? ...@@ -21,6 +20,9 @@ if Backbone?
can: (action) -> can: (action) ->
(@get('ability') || {})[action] (@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) -> updateInfo: (info) ->
if info if info
@set('ability', info.ability) @set('ability', info.ability)
...@@ -106,13 +108,21 @@ if Backbone? ...@@ -106,13 +108,21 @@ if Backbone?
@get("abuse_flaggers").pop(window.user.get('id')) @get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @ @trigger "change", @
isFlagged: ->
user = DiscussionUtil.getUser()
flaggers = @get("abuse_flaggers")
user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0))
incrementVote: (increment) ->
newVotes = _.clone(@get("votes"))
newVotes.up_count = newVotes.up_count + increment
@set("votes", newVotes)
vote: -> vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1 @incrementVote(1)
@trigger "change", @
unvote: -> unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1 @incrementVote(-1)
@trigger "change", @
class @Thread extends @Content class @Thread extends @Content
urlMappers: urlMappers:
...@@ -187,6 +197,13 @@ if Backbone? ...@@ -187,6 +197,13 @@ if Backbone?
count += comment.getCommentsCount() + 1 count += comment.getCommentsCount() + 1
count count
canBeEndorsed: =>
user_id = window.user.get("id")
user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id)
)
class @Comments extends Backbone.Collection class @Comments extends Backbone.Collection
model: Comment model: Comment
......
...@@ -34,6 +34,8 @@ if Backbone? ...@@ -34,6 +34,8 @@ if Backbone?
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 } data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode switch mode
when 'search' when 'search'
url = DiscussionUtil.urlFor 'search' url = DiscussionUtil.urlFor 'search'
...@@ -43,9 +45,6 @@ if Backbone? ...@@ -43,9 +45,6 @@ if Backbone?
data['commentable_ids'] = options.commentable_ids data['commentable_ids'] = options.commentable_ids
when 'all' when 'all'
url = DiscussionUtil.urlFor 'threads' url = DiscussionUtil.urlFor 'threads'
when 'flagged'
data['flagged'] = true
url = DiscussionUtil.urlFor 'search'
when 'followed' when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id'] if options['group_id']
......
class @DiscussionFilter class @DiscussionFilter
# TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
# for use with a very similar category dropdown in the New Post form. The two menus' implementations
# should be merged into a single reusable view.
@filterDrop: (e) -> @filterDrop: (e) ->
$drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper') $drop = $(e.target).parents('.topic-menu-wrapper')
query = $(e.target).val() query = $(e.target).val()
$items = $drop.find('a') $items = $drop.find('.topic-menu-item')
if(query.length == 0) if(query.length == 0)
$items.removeClass('hidden') $items.removeClass('hidden')
...@@ -10,19 +15,14 @@ class @DiscussionFilter ...@@ -10,19 +15,14 @@ class @DiscussionFilter
$items.addClass('hidden') $items.addClass('hidden')
$items.each (i) -> $items.each (i) ->
thisText = $(this).not('.unread').text()
$(this).parents('ul').siblings('a').not('.unread').each (i) ->
thisText = thisText + ' ' + $(this).text();
test = true
terms = thisText.split(' ')
if(thisText.toLowerCase().search(query.toLowerCase()) == -1) path = $(this).parents(".topic-menu-item").andSelf()
test = false pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ").toLowerCase()
if(test) if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
$(this).removeClass('hidden') $(this).removeClass('hidden')
# show children # show children
$(this).parent().find('a').removeClass('hidden'); $(this).find('.topic-menu-item').removeClass('hidden');
# show parents # show parents
$(this).parents('ul').siblings('a').removeClass('hidden'); $(this).parents('.topic-menu-item').removeClass('hidden');
...@@ -7,7 +7,7 @@ if Backbone? ...@@ -7,7 +7,7 @@ if Backbone?
"click .new-post-btn": "toggleNewPost" "click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn": "keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost) (event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .new-post-cancel": "hideNewPost" "click .cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage" "click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination") paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
...@@ -101,7 +101,7 @@ if Backbone? ...@@ -101,7 +101,7 @@ if Backbone?
@newPostForm = $('.new-post-article') @newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) -> @threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline"
_.each @threadviews, (dtv) -> dtv.render() _.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info) DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView( @newPostView = new NewPostView(
...@@ -124,7 +124,7 @@ if Backbone? ...@@ -124,7 +124,7 @@ if Backbone?
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1? # TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>") article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article) @$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread threadView = new DiscussionThreadView el: article, model: thread, mode: "inline"
threadView.render() threadView.render()
@threadviews.unshift threadView @threadviews.unshift threadView
......
...@@ -25,7 +25,7 @@ if Backbone? ...@@ -25,7 +25,7 @@ if Backbone?
@newPostView.render() @newPostView.render()
$('.new-post-btn').bind "click", @showNewPost $('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost) $('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
$('.new-post-cancel').bind "click", @hideNewPost @newPostView.$('.cancel').bind "click", @hideNewPost
allThreads: -> allThreads: ->
@nav.updateSidebar() @nav.updateSidebar()
...@@ -45,8 +45,12 @@ if Backbone? ...@@ -45,8 +45,12 @@ if Backbone?
if(@main) if(@main)
@main.cleanup() @main.cleanup()
@main.undelegateEvents() @main.undelegateEvents()
unless($(".forum-content").is(":visible"))
$(".forum-content").fadeIn()
if(@newPost.is(":visible"))
@newPost.fadeOut()
@main = new DiscussionThreadView(el: $(".discussion-column"), model: @thread) @main = new DiscussionThreadView(el: $(".forum-content"), model: @thread, mode: "tab")
@main.render() @main.render()
@main.on "thread:responses:rendered", => @main.on "thread:responses:rendered", =>
@nav.updateSidebar() @nav.updateSidebar()
...@@ -59,8 +63,17 @@ if Backbone? ...@@ -59,8 +63,17 @@ if Backbone?
@navigate("", trigger: true) @navigate("", trigger: true)
showNewPost: (event) => showNewPost: (event) =>
@newPost.slideDown(300) $('.forum-content').fadeOut(
$('.new-post-title').focus() duration: 200
complete: =>
@newPost.fadeIn(200)
$('.new-post-title').focus()
)
hideNewPost: (event) => hideNewPost: (event) =>
@newPost.slideUp(300) @newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200)
)
...@@ -21,15 +21,14 @@ class @DiscussionUtil ...@@ -21,15 +21,14 @@ class @DiscussionUtil
@setUser: (user) -> @setUser: (user) ->
@user = user @user = user
@getUser: () ->
@user
@loadRoles: (roles)-> @loadRoles: (roles)->
@roleIds = roles @roleIds = roles
@loadFlagModerator: (what)->
@isFlagModerator = ((what=="True") or (what == 1))
@loadRolesFromContainer: -> @loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles")) @loadRoles($("#discussion-container").data("roles"))
@loadFlagModerator($("#discussion-container").data("flag-moderator"))
@isStaff: (user_id) -> @isStaff: (user_id) ->
user_id ?= @user?.id user_id ?= @user?.id
...@@ -41,6 +40,9 @@ class @DiscussionUtil ...@@ -41,6 +40,9 @@ class @DiscussionUtil
ta = _.union(@roleIds['Community TA']) ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id)) _.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) -> @bulkUpdateContentInfo: (infos) ->
for id, info of infos for id, info of infos
Content.getContent(id).updateInfo(info) Content.getContent(id).updateInfo(info)
...@@ -159,6 +161,13 @@ class @DiscussionUtil ...@@ -159,6 +161,13 @@ class @DiscussionUtil
params["$loading"].loaded() params["$loading"].loaded()
return request return request
@updateWithUndo: (model, updates, safeAjaxParams, errorMsg) ->
if errorMsg
safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg)
undo = _.pick(model.attributes, _.keys(updates))
model.set(updates)
@safeAjax(safeAjaxParams).fail(() -> model.set(undo))
@bindLocalEvents: ($local, eventsHandler) -> @bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ') [event, selector] = eventSelector.split(' ')
...@@ -167,7 +176,7 @@ class @DiscussionUtil ...@@ -167,7 +176,7 @@ class @DiscussionUtil
@formErrorHandler: (errorsField) -> @formErrorHandler: (errorsField) ->
(xhr, textStatus, error) -> (xhr, textStatus, error) ->
makeErrorElem = (message) -> makeErrorElem = (message) ->
$("<li>").addClass("new-post-form-error").html(message) $("<li>").addClass("post-error").html(message)
errorsField.empty().show() errorsField.empty().show()
if xhr.status == 400 if xhr.status == 400
response = JSON.parse(xhr.responseText) response = JSON.parse(xhr.responseText)
......
...@@ -10,6 +10,7 @@ if Backbone? ...@@ -10,6 +10,7 @@ if Backbone?
"change .forum-nav-sort-control": "sortThreads" "change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected" "click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages" "click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort" "change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: -> initialize: ->
...@@ -75,7 +76,7 @@ if Backbone? ...@@ -75,7 +76,7 @@ if Backbone?
#TODO fix this entire chain of events #TODO fix this entire chain of events
addAndSelectThread: (thread) => addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id") commentable_id = thread.get("commentable_id")
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id").id == commentable_id) menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id)
@setCurrentTopicDisplay(@getPathText(menuItem)) @setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, => @retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id') @trigger "thread:created", thread.get('id')
...@@ -173,7 +174,7 @@ if Backbone? ...@@ -173,7 +174,7 @@ if Backbone?
loadingElem = loadMoreElem.find(".forum-nav-loading") loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem) DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus() loadingElem.focus()
options = {} options = {filter: @filter}
switch @mode switch @mode
when 'search' when 'search'
options.search_text = @current_search options.search_text = @current_search
...@@ -242,7 +243,7 @@ if Backbone? ...@@ -242,7 +243,7 @@ if Backbone?
goHome: -> goHome: ->
@template = _.template($("#discussion-home").html()) @template = _.template($("#discussion-home").html())
$(".discussion-column").html(@template) $(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active") $(".forum-nav-thread-list a").removeClass("is-active")
$("input.email-setting").bind "click", @updateEmailNotifications $("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id")) url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
...@@ -363,26 +364,24 @@ if Backbone? ...@@ -363,26 +364,24 @@ if Backbone?
@discussionIds = "" @discussionIds = ""
@$('.forum-nav-filter-cohort').show() @$('.forum-nav-filter-cohort').show()
@retrieveAllThreads() @retrieveAllThreads()
else if item.hasClass("forum-nav-browse-menu-flagged")
@discussionIds = ""
@$('.forum-nav-filter-cohort').hide()
@retrieveFlaggedThreads()
else if item.hasClass("forum-nav-browse-menu-following") else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed() @retrieveFollowed()
@$('.forum-nav-filter-cohort').hide() @$('.forum-nav-filter-cohort').hide()
else else
allItems = item.find(".forum-nav-browse-menu-item").andSelf() allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map( discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id").id (i, elem) -> $(elem).data("discussion-id")
).get() ).get()
@retrieveDiscussions(discussionIds) @retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true) @$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true)
chooseCohort: (event) -> chooseFilter: (event) =>
@filter = $(".forum-nav-filter-main-control :selected").val()
@retrieveFirstPage()
chooseCohort: (event) =>
@group_id = @$('.forum-nav-filter-cohort-control :selected').val() @group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@collection.current_page = 0 @retrieveFirstPage()
@collection.reset()
@loadMorePages(event)
retrieveDiscussion: (discussion_id, callback=null) -> retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id) url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
...@@ -413,12 +412,6 @@ if Backbone? ...@@ -413,12 +412,6 @@ if Backbone?
@collection.reset() @collection.reset()
@loadMorePages(event) @loadMorePages(event)
retrieveFlaggedThreads: (event)->
@collection.current_page = 0
@collection.reset()
@mode = 'flagged'
@loadMorePages(event)
sortThreads: (event) -> sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val()) @displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
...@@ -434,6 +427,7 @@ if Backbone? ...@@ -434,6 +427,7 @@ if Backbone?
searchFor: (text) -> searchFor: (text) ->
@clearSearchAlerts() @clearSearchAlerts()
@clearFilters()
@mode = 'search' @mode = 'search'
@current_search = text @current_search = text
url = DiscussionUtil.urlFor("search") url = DiscussionUtil.urlFor("search")
...@@ -499,6 +493,11 @@ if Backbone? ...@@ -499,6 +493,11 @@ if Backbone?
clearSearch: -> clearSearch: ->
@$(".forum-nav-search-input").val("") @$(".forum-nav-search-input").val("")
@current_search = "" @current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () => retrieveFollowed: () =>
@mode = 'followed' @mode = 'followed'
......
if Backbone? if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
"click .admin-pin":
(event) -> @togglePin(event)
"keydown .admin-pin":
(event) -> DiscussionUtil.activateOnSpace(event, @togglePin)
"click .action-follow": "toggleFollowing"
"keydown .action-follow":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFollowing)
"click .action-edit": "edit"
"click .action-delete": "_delete"
"click .action-openclose": "toggleClosed"
$: (selector) ->
@$el.find(selector)
initialize: ->
super() super()
@model.on "change", @updateModelDetails @mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-show-template").html()) @template = _.template($("#thread-show-template").html())
@template(@model.toJSON()) context = $.extend(
{
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid
},
@model.attributes,
)
@template(context)
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
@renderVote()
@renderFlagged()
@renderPinned()
@renderAttrs() @renderAttrs()
@$("span.timeago").timeago() @$("span.timeago").timeago()
@convertMath() @convertMath()
...@@ -44,60 +29,6 @@ if Backbone? ...@@ -44,60 +29,6 @@ if Backbone?
@highlight @$("h1,h3") @highlight @$("h1,h3")
@ @
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
renderPinned: =>
pinElem = @$(".discussion-pin")
pinLabelElem = pinElem.find(".pin-label")
if @model.get("pinned")
pinElem.addClass("pinned")
pinElem.removeClass("notpinned")
if @model.can("can_openclose")
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
pinLabelElem.html(
interpolate(
gettext("Pinned%(start_sr_span)s, click to unpin%(end_span)s"),
{"start_sr_span": "<span class='sr'>", "end_span": "</span>"},
true
)
)
pinElem.attr("data-tooltip", gettext("Click to unpin"))
pinElem.attr("aria-pressed", "true")
else
pinLabelElem.html(gettext("Pinned"))
pinElem.removeAttr("data-tooltip")
pinElem.removeAttr("aria-pressed")
else
# If not pinned and not able to pin, pin is not shown
pinElem.removeClass("pinned")
pinElem.addClass("notpinned")
pinLabelElem.html(gettext("Pin Thread"))
pinElem.removeAttr("data-tooltip")
pinElem.attr("aria-pressed", "false")
updateModelDetails: =>
@renderVote()
@renderFlagged()
@renderPinned()
convertMath: -> convertMath: ->
element = @$(".post-body") element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
...@@ -109,74 +40,6 @@ if Backbone? ...@@ -109,74 +40,6 @@ if Backbone?
_delete: (event) -> _delete: (event) ->
@trigger "thread:_delete", event @trigger "thread:_delete", event
togglePin: (event) =>
event.preventDefault()
if @model.get('pinned')
@unPin()
else
@pin()
pin: =>
url = @model.urlFor("pinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', true)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble pinning this thread. Please try again.")
unPin: =>
url = @model.urlFor("unPinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', false)
error: =>
DiscussionUtil.discussionAlert("Sorry", "We had some trouble unpinning this thread. Please try again.")
toggleClosed: (event) ->
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
toggleEndorse: (event) ->
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
highlight: (el) -> highlight: (el) ->
if el.html() if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>")) el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))
class @DiscussionThreadInlineShowView extends DiscussionThreadShowView
renderTemplate: ->
@template = DiscussionUtil.getTemplate('_inline_thread_show')
params = @model.toJSON()
if @model.get('username')?
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
Mustache.render(@template, params)
...@@ -7,14 +7,25 @@ if Backbone? ...@@ -7,14 +7,25 @@ if Backbone?
events: events:
"click .discussion-submit-post": "submitComment" "click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse" "click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) -> $: (selector) ->
@$el.find(selector) @$el.find(selector)
initialize: -> isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super() super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@createShowView() @createShowView()
@responses = new Comments() @responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-template").html()) @template = _.template($("#thread-template").html())
...@@ -22,7 +33,6 @@ if Backbone? ...@@ -22,7 +33,6 @@ if Backbone?
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@initLocal()
@delegateEvents() @delegateEvents()
@renderShowView() @renderShowView()
...@@ -31,11 +41,53 @@ if Backbone? ...@@ -31,11 +41,53 @@ if Backbone?
@$("span.timeago").timeago() @$("span.timeago").timeago()
@makeWmdEditor "reply-body" @makeWmdEditor "reply-body"
@renderAddResponseButton() @renderAddResponseButton()
@responses.on("add", @renderResponse) @responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {}))
# Without a delay, jQuery doesn't add the loading extension defined in if @isQuestion()
# utils.coffee before safeAjax is invoked, which results in an error @markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true}))
setTimeout((=> @loadInitialResponses()), 100) 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: -> cleanup: ->
if @responsesRequest? if @responsesRequest?
...@@ -54,9 +106,20 @@ if Backbone? ...@@ -54,9 +106,20 @@ if Backbone?
@responseRequest = null @responseRequest = null
success: (data, textStatus, xhr) => success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info']) Content.loadContentInfos(data['annotated_content_info'])
@responses.add(data['content']['children']) if @isQuestion()
@renderResponseCountAndPagination(data['content']['resp_total']) @markedAnswers.add(data["content"]["endorsed_responses"])
@responses.add(
if @isQuestion()
then data["content"]["non_endorsed_responses"]
else data["content"]["children"]
)
@renderResponseCountAndPagination(
if @isQuestion()
then data["content"]["non_endorsed_resp_total"]
else data["content"]["resp_total"]
)
@trigger "thread:responses:rendered" @trigger "thread:responses:rendered"
@loadedResponses = true
error: (xhr) => error: (xhr) =>
if xhr.status == 404 if xhr.status == 404
DiscussionUtil.discussionAlert( DiscussionUtil.discussionAlert(
...@@ -75,16 +138,24 @@ if Backbone? ...@@ -75,16 +138,24 @@ if Backbone?
) )
loadInitialResponses: () -> loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true) @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) => renderResponseCountAndPagination: (responseTotal) =>
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( @$el.find(".response-count").html(
interpolate( interpolate(
ngettext( responseCountFormat,
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
),
{numResponses: responseTotal}, {numResponses: responseTotal},
true true
) )
...@@ -126,17 +197,17 @@ if Backbone? ...@@ -126,17 +197,17 @@ if Backbone?
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton)) loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton) responsePagination.append(loadMoreButton)
renderResponse: (response) => renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model) response.set('thread', @model)
view = new ThreadResponseView(model: response) view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread view.on "comment:endorse", @endorseThread
view.render() view.render()
@$el.find(".responses").append(view.el) @$el.find(listSelector).append(view.el)
view.afterInsert() view.afterInsert()
renderAddResponseButton: -> renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show() @$el.find('div.add-response').show()
else else
@$el.find('div.add-response').hide() @$el.find('div.add-response').hide()
...@@ -150,9 +221,8 @@ if Backbone? ...@@ -150,9 +221,8 @@ if Backbone?
addComment: => addComment: =>
@model.comment() @model.comment()
endorseThread: (endorsed) => endorseThread: =>
is_endorsed = @$el.find(".is-endorsed").length @model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
@model.set 'endorsed', is_endorsed
submitComment: (event) -> submitComment: (event) ->
event.preventDefault() event.preventDefault()
...@@ -162,7 +232,7 @@ if Backbone? ...@@ -162,7 +232,7 @@ if Backbone?
@setWmdContent("reply-body", "") @setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id")) comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread')) comment.set('thread', @model.get('thread'))
@renderResponse(comment) @renderResponseToList(comment, ".js-response-list")
@model.addComment() @model.addComment()
@renderAddResponseButton() @renderAddResponseButton()
...@@ -209,6 +279,7 @@ if Backbone? ...@@ -209,6 +279,7 @@ if Backbone?
@model.set @model.set
title: newTitle title: newTitle
body: newBody body: newBody
@model.unset("abbreviatedBody")
@createShowView() @createShowView()
@renderShowView() @renderShowView()
...@@ -232,9 +303,6 @@ if Backbone? ...@@ -232,9 +303,6 @@ if Backbone?
renderEditView: () -> renderEditView: () ->
@renderSubView(@editView) @renderSubView(@editView)
getShowViewClass: () ->
return DiscussionThreadShowView
createShowView: () -> createShowView: () ->
if @editView? if @editView?
...@@ -242,8 +310,7 @@ if Backbone? ...@@ -242,8 +310,7 @@ if Backbone?
@editView.$el.empty() @editView.$el.empty()
@editView = null @editView = null
showViewClass = @getShowViewClass() @showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView = new showViewClass(model: @model)
@showView.bind "thread:_delete", @_delete @showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit @showView.bind "thread:edit", @edit
......
if Backbone?
class @DiscussionThreadInlineView extends DiscussionThreadView
expanded = false
events:
"click .discussion-submit-post": "submitComment"
"click .expand-post": "expandPost"
"click .collapse-post": "collapsePost"
"click .add-response-btn": "scrollToAddResponse"
initialize: ->
super()
initLocal: ->
@$local = @$el.children(".discussion-article").children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
renderTemplate: () ->
if @model.has('group_id')
@template = DiscussionUtil.getTemplate("_inline_thread_cohorted")
else
@template = DiscussionUtil.getTemplate("_inline_thread")
if not @model.has('abbreviatedBody')
@abbreviateBody()
params = @model.toJSON()
Mustache.render(@template, params)
render: () ->
super()
@$el.find('.post-extended-content').hide()
@$el.find('.collapse-post').hide()
getShowViewClass: () ->
return DiscussionThreadInlineShowView
loadInitialResponses: () ->
if @expanded
super()
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140
@model.set('abbreviatedBody', abbreviated)
expandPost: (event) =>
@$el.addClass('expanded')
@$el.find('.post-body').html(@model.get('body'))
@showView.convertMath()
@$el.find('.expand-post').css('display', 'none')
@$el.find('.collapse-post').css('display', 'block')
@$el.find('.post-extended-content').show()
if not @expanded
@expanded = true
@loadInitialResponses()
collapsePost: (event) ->
curScroll = $(window).scrollTop()
postTop = @$el.offset().top
if postTop < curScroll
$('html, body').animate({scrollTop: postTop})
@$el.removeClass('expanded')
@$el.find('.post-body').html(@model.get('abbreviatedBody'))
@showView.convertMath()
@$el.find('.expand-post').css('display', 'block')
@$el.find('.collapse-post').css('display', 'none')
@$el.find('.post-extended-content').hide()
createEditView: () ->
super()
@editView.bind "thread:update", @abbreviateBody
if Backbone? if Backbone?
class @ResponseCommentShowView extends DiscussionContentView class @ResponseCommentShowView extends DiscussionContentShowView
events:
"click .action-delete":
(event) -> @_delete(event)
"keydown .action-delete":
(event) -> DiscussionUtil.activateOnSpace(event, @_delete)
"click .action-edit":
(event) -> @edit(event)
"keydown .action-edit":
(event) -> DiscussionUtil.activateOnSpace(event, @edit)
tagName: "li" tagName: "li"
initialize: ->
super()
@model.on "change", @updateModelDetails
abilityRenderer:
can_delete:
enable: -> @$(".action-delete").show()
disable: -> @$(".action-delete").hide()
editable:
enable: -> @$(".action-edit").show()
disable: -> @$(".action-edit").hide()
render: -> render: ->
@template = _.template($("#response-comment-show-template").html()) @template = _.template($("#response-comment-show-template").html())
params = @model.toJSON() @$el.html(
@template(
_.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay()
},
@model.attributes
)
)
)
@$el.html(@template(params))
@initLocal()
@delegateEvents() @delegateEvents()
@renderAttrs() @renderAttrs()
@renderFlagged()
@markAsStaff()
@$el.find(".timeago").timeago() @$el.find(".timeago").timeago()
@convertMath() @convertMath()
@addReplyLink() @addReplyLink()
...@@ -52,31 +35,8 @@ if Backbone? ...@@ -52,31 +35,8 @@ if Backbone?
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text() body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]] MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="staff-label">' + gettext('staff') + '</span>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="community-ta-label">' + gettext('Community TA') + '</span>')
_delete: (event) => _delete: (event) =>
@trigger "comment:_delete", event @trigger "comment:_delete", event
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Misuse Reported, click to remove report"))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Report Misuse"))
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderFlagged()
edit: (event) => edit: (event) =>
@trigger "comment:edit", event @trigger "comment:edit", event
if Backbone? if Backbone?
class @ThreadResponseShowView extends DiscussionContentView class @ThreadResponseShowView extends DiscussionContentShowView
events:
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleVote)
"click .action-endorse": "toggleEndorse"
"click .action-delete": "_delete"
"click .action-edit": "edit"
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
$: (selector) ->
@$el.find(selector)
initialize: -> initialize: ->
super() super()
@model.on "change", @updateModelDetails @listenTo(@model, "change", @render)
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-response-show-template").html()) @template = _.template($("#thread-response-show-template").html())
@template(@model.toJSON()) context = _.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay()
},
@model.attributes
)
@template(context)
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
@renderVote()
@renderAttrs() @renderAttrs()
@renderFlagged() @$el.find(".posted-details .timeago").timeago()
@$el.find(".posted-details").timeago()
@convertMath() @convertMath()
@markAsStaff()
@ @
convertMath: -> convertMath: ->
...@@ -39,54 +29,8 @@ if Backbone? ...@@ -39,54 +29,8 @@ if Backbone?
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]] MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.addClass("staff")
@$el.prepend('<div class="staff-banner">' + gettext('staff') + '</div>')
else if DiscussionUtil.isTA(@model.get("user_id"))
@$el.addClass("community-ta")
@$el.prepend('<div class="community-ta-banner">' + gettext('Community TA') + '</div>')
edit: (event) -> edit: (event) ->
@trigger "response:edit", event @trigger "response:edit", event
_delete: (event) -> _delete: (event) ->
@trigger "response:_delete", event @trigger "response:_delete", event
toggleEndorse: (event) ->
event.preventDefault()
if not @model.can('can_endorse')
return
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
@model.set('endorsed', not endorsed)
@trigger "comment:endorse", not endorsed
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged")
@$("[data-role=thread-flag]").removeClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "true")
@$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report"))
###
Translators: The text between start_sr_span and end_span is not shown
in most browsers but will be read by screen readers.
###
@$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "<span class='sr'>", "end_span": "</span>"}, true))
else
@$("[data-role=thread-flag]").removeClass("flagged")
@$("[data-role=thread-flag]").addClass("notflagged")
@$(".discussion-flag-abuse").attr("aria-pressed", "false")
@$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse"))
updateModelDetails: =>
@renderVote()
@renderFlagged()
if Backbone? if Backbone?
class @ThreadResponseView extends DiscussionContentView class @ThreadResponseView extends DiscussionContentView
tagName: "li" tagName: "li"
className: "forum-response"
events: events:
"click .discussion-submit-comment": "submitComment" "click .discussion-submit-comment": "submitComment"
...@@ -9,7 +10,8 @@ if Backbone? ...@@ -9,7 +10,8 @@ if Backbone?
$: (selector) -> $: (selector) ->
@$el.find(selector) @$el.find(selector)
initialize: -> initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView() @createShowView()
renderTemplate: -> renderTemplate: ->
...@@ -65,6 +67,15 @@ if Backbone? ...@@ -65,6 +67,15 @@ if Backbone?
collectComments(child) collectComments(child)
@model.get('comments').each collectComments @model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null) comments.each (comment) => @renderComment(comment, false, null)
if @collapseComments && comments.length
@$(".comments").hide()
@$(".action-show-comments").on("click", (event) =>
event.preventDefault()
@$(".action-show-comments").hide()
@$(".comments").show()
)
else
@$(".action-show-comments").hide()
renderComment: (comment) => renderComment: (comment) =>
comment.set('thread', @model.get('thread')) comment.set('thread', @model.get('thread'))
...@@ -155,6 +166,7 @@ if Backbone? ...@@ -155,6 +166,7 @@ if Backbone?
@showView = new ThreadResponseShowView(model: @model) @showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete @showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit @showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () -> renderShowView: () ->
@renderSubView(@showView) @renderSubView(@showView)
......
...@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory): ...@@ -30,6 +30,7 @@ class ContentFactory(factory.Factory):
class Thread(ContentFactory): class Thread(ContentFactory):
thread_type = "discussion"
anonymous = False anonymous = False
anonymous_to_peers = False anonymous_to_peers = False
comments_count = 0 comments_count = 0
...@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture): ...@@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture):
def addResponse(self, response, comments=[]): def addResponse(self, response, comments=[]):
response['children'] = comments response['children'] = comments
self.thread.setdefault('children', []).append(response) if self.thread["thread_type"] == "discussion":
responseListAttr = "children"
elif response["endorsed"]:
responseListAttr = "endorsed_responses"
else:
responseListAttr = "non_endorsed_responses"
self.thread.setdefault(responseListAttr, []).append(response)
self.thread['comments_count'] += len(comments) + 1 self.thread['comments_count'] += len(comments) + 1
def _get_comment_map(self): def _get_comment_map(self):
......
from contextlib import contextmanager
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
...@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
query = self._find_within(selector) query = self._find_within(selector)
return query.present and query.visible return query.present and query.visible
@contextmanager
def _secondary_action_menu_open(self, ancestor_selector):
"""
Given the selector for an ancestor of a secondary menu, return a context
manager that will open and close the menu
"""
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu opened"
).fulfill()
yield
if self._is_element_visible(ancestor_selector + " .actions-dropdown"):
self._find_within(ancestor_selector + " .action-more").click()
EmptyPromise(
lambda: not self._is_element_visible(ancestor_selector + " .actions-dropdown"),
"Secondary action menu closed"
).fulfill()
def get_response_total_text(self): def get_response_total_text(self):
"""Returns the response count text, or None if not present""" """Returns the response count text, or None if not present"""
return self._get_element_text(".response-count") return self._get_element_text(".response-count")
...@@ -89,10 +110,23 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -89,10 +110,23 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_response_edit(self, response_id): def start_response_edit(self, response_id):
"""Click the edit button for the response, loading the editing view""" """Click the edit button for the response, loading the editing view"""
self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click() with self._secondary_action_menu_open(".response_{} .discussion-response".format(response_id)):
self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click()
EmptyPromise(
lambda: self.is_response_editor_visible(response_id),
"Response edit started"
).fulfill()
def is_show_comments_visible(self, response_id):
"""Returns true if the "show comments" link is visible for a response"""
return self._is_element_visible(".response_{} .action-show-comments".format(response_id))
def show_comments(self, response_id):
"""Click the "show comments" link for a response"""
self._find_within(".response_{} .action-show-comments".format(response_id)).first.click()
EmptyPromise( EmptyPromise(
lambda: self.is_response_editor_visible(response_id), lambda: self._is_element_visible(".response_{} .comments".format(response_id)),
"Response edit started" "Comments shown"
).fulfill() ).fulfill()
def is_add_comment_visible(self, response_id): def is_add_comment_visible(self, response_id):
...@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_deletable(self, comment_id): def is_comment_deletable(self, comment_id):
"""Returns true if the delete comment button is present, false otherwise""" """Returns true if the delete comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} div.action-delete".format(comment_id)) with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-delete".format(comment_id))
def delete_comment(self, comment_id): def delete_comment(self, comment_id):
with self.handle_alert(): with self.handle_alert():
self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click() with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
self._find_within("#comment_{} .action-delete".format(comment_id)).first.click()
EmptyPromise( EmptyPromise(
lambda: not self.is_comment_visible(comment_id), lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed" "Deleted comment was removed"
...@@ -120,7 +156,8 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -120,7 +156,8 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def is_comment_editable(self, comment_id): def is_comment_editable(self, comment_id):
"""Returns true if the edit comment button is present, false otherwise""" """Returns true if the edit comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} .action-edit".format(comment_id)) with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
return self._is_element_visible("#comment_{} .action-edit".format(comment_id))
def is_comment_editor_visible(self, comment_id): def is_comment_editor_visible(self, comment_id):
"""Returns true if the comment editor is present, false otherwise""" """Returns true if the comment editor is present, false otherwise"""
...@@ -132,15 +169,16 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -132,15 +169,16 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
def start_comment_edit(self, comment_id): def start_comment_edit(self, comment_id):
"""Click the edit button for the comment, loading the editing view""" """Click the edit button for the comment, loading the editing view"""
old_body = self.get_comment_body(comment_id) old_body = self.get_comment_body(comment_id)
self._find_within("#comment_{} .action-edit".format(comment_id)).first.click() with self._secondary_action_menu_open("#comment_{}".format(comment_id)):
EmptyPromise( self._find_within("#comment_{} .action-edit".format(comment_id)).first.click()
lambda: ( EmptyPromise(
self.is_comment_editor_visible(comment_id) and lambda: (
not self.is_comment_visible(comment_id) and self.is_comment_editor_visible(comment_id) and
self._get_comment_editor_value(comment_id) == old_body not self.is_comment_visible(comment_id) and
), self._get_comment_editor_value(comment_id) == old_body
"Comment edit started" ),
).fulfill() "Comment edit started"
).fulfill()
def set_comment_editor_value(self, comment_id, new_body): def set_comment_editor_value(self, comment_id, new_body):
"""Replace the contents of the comment editor""" """Replace the contents of the comment editor"""
...@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage): ...@@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage):
def expand(self): def expand(self):
"""Clicks the link to expand the thread""" """Clicks the link to expand the thread"""
self._find_within(".expand-post").first.click() self._find_within(".forum-thread-expand").first.click()
EmptyPromise( EmptyPromise(
lambda: bool(self.get_response_total_text()), lambda: bool(self.get_response_total_text()),
"Thread expanded" "Thread expanded"
......
...@@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati ...@@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati
self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201 self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201
self.thread_page.visit() self.thread_page.visit()
def test_marked_answer_comments(self):
thread_id = "test_thread_{}".format(uuid4().hex)
response_id = "test_response_{}".format(uuid4().hex)
comment_id = "test_comment_{}".format(uuid4().hex)
thread_fixture = SingleThreadViewFixture(
Thread(id=thread_id, commentable_id=self.discussion_id, thread_type="question")
)
thread_fixture.addResponse(
Response(id=response_id, endorsed=True),
[Comment(id=comment_id)]
)
thread_fixture.push()
self.setup_thread_page(thread_id)
self.assertFalse(self.thread_page.is_comment_visible(comment_id))
self.assertFalse(self.thread_page.is_add_comment_visible(response_id))
self.assertTrue(self.thread_page.is_show_comments_visible(response_id))
self.thread_page.show_comments(response_id)
self.assertTrue(self.thread_page.is_comment_visible(comment_id))
self.assertTrue(self.thread_page.is_add_comment_visible(response_id))
self.assertFalse(self.thread_page.is_show_comments_visible(response_id))
@attr('shard_1') @attr('shard_1')
class DiscussionCommentDeletionTest(UniqueCourseTest): class DiscussionCommentDeletionTest(UniqueCourseTest):
......
############ ############
Change Log Change Log
############ ############
*****************
September, 2014
*****************
.. list-table::
:widths: 10 70
:header-rows: 1
* - Date
- Change
* - 09/02/14
- Updated the :ref:`Discussions` and :ref:`Discussions for Students and
Staff` chapters to include information about choosing the type of post
and to reflect changes in the user interface.
************** **************
August, 2014 August, 2014
......
...@@ -51,7 +51,7 @@ Create a Discussion Component ...@@ -51,7 +51,7 @@ Create a Discussion Component
course content. The values in the **Category** and **Subcategory** fields course content. The values in the **Category** and **Subcategory** fields
appear in the list of discussion topics on the **Discussion** page. To appear in the list of discussion topics on the **Discussion** page. To
uniquely identify the discussion in your course, each **Category** / uniquely identify the discussion in your course, each **Category** /
**Subcategory** pair that you supply should be unique. **Subcategory** pair that you supply must be unique.
.. image:: ../Images/Discussion_category_subcategory.png .. image:: ../Images/Discussion_category_subcategory.png
:alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded" :alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded"
...@@ -102,4 +102,4 @@ In the **Discussion** tab at the top of the page, students can find the ...@@ -102,4 +102,4 @@ In the **Discussion** tab at the top of the page, students can find the
category and subcategory of the discussion in the left pane. category and subcategory of the discussion in the left pane.
.. image:: ../Images/Discussion_category_subcategory.png .. image:: ../Images/Discussion_category_subcategory.png
:alt: Image of the Discussion page from a student's point of view :alt: Image of the Discussion page from a student's point of view
\ No newline at end of file
...@@ -161,7 +161,13 @@ D ...@@ -161,7 +161,13 @@ D
**Discussion** **Discussion**
The set of topics defined to promote course-wide or unit-specific dialog. Students use the discussion topics to communicate with each other and the course staff in threaded excahnges. The set of topics defined to promote course-wide or unit-specific
conversation. Students use the discussion topics to communicate with each
other and the course staff in threaded exchanges.
A discussion is also a type of contribution that you can make to a topic to
start an open-ended dialogue. You can also contribute questions to the
discussion topics.
See :ref:`Discussions` for more information. See :ref:`Discussions` for more information.
...@@ -433,6 +439,29 @@ P ...@@ -433,6 +439,29 @@ P
The page in the learning management system that shows students their scores on graded assignments in the course. The page in the learning management system that shows students their scores on graded assignments in the course.
.. _Public Unit:
**Public Unit**
A unit whose **Visibility** option is set to Public so that the unit is visible to students, if the subsection that contains the unit has been released.
See :ref:`Public and Private Units` for more information.
.. _Q:
*****
Q
*****
**Question**
A question is a type of contribution that you can make to a course discussion
topic to surface an issue that the course staff or other students can
resolve.
See :ref:`Discussions` for more information.
.. _R: .. _R:
**** ****
......
...@@ -11,6 +11,10 @@ Change Log ...@@ -11,6 +11,10 @@ Change Log
* - Date * - Date
- Change - Change
* - 09/02/14
- Updated the :ref:`Discussion Forums Data` chapter to include the
``thread_type`` field for CommentThreads and the ``endorsement`` field
for Comments.
* - 08/25/14 * - 08/25/14
- Removed information on course grading. See `Establishing a Grading - Removed information on course grading. See `Establishing a Grading
Policy <http://edx.readthedocs.org/projects/edx-partner-course- Policy <http://edx.readthedocs.org/projects/edx-partner-course-
......
...@@ -215,7 +215,12 @@ on the server. The values in this field are: ...@@ -215,7 +215,12 @@ on the server. The values in this field are:
**Type:** string **Type:** string
**Details:** The type of event triggered. Values depend on ``event_source``. **Details:** The type of event triggered. Values depend on ``event_source``.
The :ref:`Student_Event_Types` and :ref:`Instructor_Event_Types` sections in
this chapter provide descriptions of each type of event that is included in
data packages. To locate information about a specific event type, see the
:ref:`event_list`.
=================== ===================
``host`` Field ``host`` Field
...@@ -2589,4 +2594,4 @@ members also generate enrollment events. ...@@ -2589,4 +2594,4 @@ members also generate enrollment events.
For details about the enrollment events, see :ref:`enrollment`. For details about the enrollment events, see :ref:`enrollment`.
.. _Creating a Peer Assessment: http://edx.readthedocs.org/projects/edx-open-response-assessments/en/latest/ .. _Creating a Peer Assessment: http://edx.readthedocs.org/projects/edx-open-response-assessments/en/latest/
\ No newline at end of file
...@@ -6,7 +6,7 @@ from django.test.utils import override_settings ...@@ -6,7 +6,7 @@ from django.test.utils import override_settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch, ANY from mock import patch, ANY, Mock
from nose.tools import assert_true, assert_equal # pylint: disable=E0611 from nose.tools import assert_true, assert_equal # pylint: disable=E0611
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -26,9 +26,11 @@ CS_PREFIX = "http://localhost:4567/api/v1" ...@@ -26,9 +26,11 @@ CS_PREFIX = "http://localhost:4567/api/v1"
class MockRequestSetupMixin(object): class MockRequestSetupMixin(object):
def _create_repsonse_mock(self, data):
return Mock(text=json.dumps(data), json=Mock(return_value=data))\
def _set_mock_request_data(self, mock_request, data): def _set_mock_request_data(self, mock_request, data):
mock_request.return_value.text = json.dumps(data) mock_request.return_value = self._create_repsonse_mock(data)
mock_request.return_value.json.return_value = data
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
...@@ -72,6 +74,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -72,6 +74,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
def test_create_thread(self, mock_request): def test_create_thread(self, mock_request):
mock_request.return_value.status_code = 200 mock_request.return_value.status_code = 200
self._set_mock_request_data(mock_request, { self._set_mock_request_data(mock_request, {
"thread_type": "discussion",
"title": "Hello", "title": "Hello",
"body": "this is a post", "body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course", "course_id": "MITx/999/Robot_Super_Course",
...@@ -100,12 +103,14 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -100,12 +103,14 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
"read": False, "read": False,
"comments_count": 0, "comments_count": 0,
}) })
thread = {"body": ["this is a post"], thread = {
"anonymous_to_peers": ["false"], "thread_type": "discussion",
"auto_subscribe": ["false"], "body": ["this is a post"],
"anonymous": ["false"], "anonymous_to_peers": ["false"],
"title": ["Hello"] "auto_subscribe": ["false"],
} "anonymous": ["false"],
"title": ["Hello"],
}
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': self.course_id.to_deprecated_string()}) 'course_id': self.course_id.to_deprecated_string()})
response = self.client.post(url, data=thread) response = self.client.post(url, data=thread)
...@@ -114,6 +119,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -114,6 +119,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
'post', 'post',
'{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX), '{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX),
data={ data={
'thread_type': 'discussion',
'body': u'this is a post', 'body': u'this is a post',
'anonymous_to_peers': False, 'user_id': 1, 'anonymous_to_peers': False, 'user_id': 1,
'title': u'Hello', 'title': u'Hello',
...@@ -616,6 +622,53 @@ class ViewPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet ...@@ -616,6 +622,53 @@ class ViewPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def _set_mock_request_thread_and_comment(self, mock_request, thread_data, comment_data):
def handle_request(*args, **kwargs):
url = args[1]
if "/threads/" in url:
return self._create_repsonse_mock(thread_data)
elif "/comments/" in url:
return self._create_repsonse_mock(comment_data)
else:
raise ArgumentError("Bad url to mock request")
mock_request.side_effect = handle_request
def test_endorse_response_as_staff(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.student.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.moderator.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 200)
def test_endorse_response_as_student(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.moderator.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 401)
def test_endorse_response_as_student_question_author(self, mock_request):
self._set_mock_request_thread_and_comment(
mock_request,
{"type": "thread", "thread_type": "question", "user_id": str(self.student.id)},
{"type": "comment", "thread_id": "dummy"}
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.post(
reverse("endorse_comment", kwargs={"course_id": self.course.id.to_deprecated_string(), "comment_id": "dummy"})
)
self.assertEqual(response.status_code, 200)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRequestSetupMixin): class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRequestSetupMixin):
...@@ -628,7 +681,7 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq ...@@ -628,7 +681,7 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq
@patch('lms.lib.comment_client.utils.requests.request') @patch('lms.lib.comment_client.utils.requests.request')
def _test_unicode_data(self, text, mock_request): def _test_unicode_data(self, text, mock_request):
self._set_mock_request_data(mock_request, {}) self._set_mock_request_data(mock_request, {})
request = RequestFactory().post("dummy_url", {"body": text, "title": text}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text})
request.user = self.student request.user = self.student
request.view_name = "create_thread" request.view_name = "create_thread"
response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable") response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable")
......
...@@ -103,11 +103,24 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -103,11 +103,24 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
#so by default, a moderator sees all items, and a student sees his cohort #so by default, a moderator sees all items, and a student sees his cohort
query_params = merge_dict(default_query_params, query_params = merge_dict(
strip_none(extract(request.GET, default_query_params,
['page', 'sort_key', strip_none(
'sort_order', 'text', extract(
'commentable_ids', 'flagged']))) request.GET,
[
'page',
'sort_key',
'sort_order',
'text',
'commentable_ids',
'flagged',
'unread',
'unanswered',
]
)
)
)
threads, page, num_pages, corrected_text = cc.Thread.search(query_params) threads, page, num_pages, corrected_text = cc.Thread.search(query_params)
...@@ -150,7 +163,7 @@ def inline_discussion(request, course_id, discussion_id): ...@@ -150,7 +163,7 @@ def inline_discussion(request, course_id, discussion_id):
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'user_info': user_info, 'user_info': user_info,
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
'page': query_params['page'], 'page': query_params['page'],
...@@ -173,7 +186,7 @@ def forum_form_discussion(request, course_id): ...@@ -173,7 +186,7 @@ def forum_form_discussion(request, course_id):
try: try:
unsafethreads, query_params = get_threads(request, course_id) # This might process a search query unsafethreads, query_params = get_threads(request, course_id) # This might process a search query
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
threads = [utils.safe_content(thread, is_staff) for thread in unsafethreads] threads = [utils.safe_content(thread, course_id, is_staff) for thread in unsafethreads]
except cc.utils.CommentClientMaintenanceError: except cc.utils.CommentClientMaintenanceError:
log.warning("Forum is in maintenance mode") log.warning("Forum is in maintenance mode")
return render_to_response('discussion/maintenance.html', {}) return render_to_response('discussion/maintenance.html', {})
...@@ -253,7 +266,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -253,7 +266,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if request.is_ajax(): if request.is_ajax():
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"): with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
content = utils.safe_content(thread.to_dict(), is_staff) content = utils.safe_content(thread.to_dict(), course_id, is_staff)
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"): with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
add_courseware_context([content], course) add_courseware_context([content], course)
return utils.JsonResponse({ return utils.JsonResponse({
...@@ -276,7 +289,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -276,7 +289,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
if not "pinned" in thread: if not "pinned" in thread:
thread["pinned"] = False thread["pinned"] = False
threads = [utils.safe_content(thread, is_staff) for thread in threads] threads = [utils.safe_content(thread, course_id, is_staff) for thread in threads]
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"): with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
...@@ -335,7 +348,7 @@ def user_profile(request, course_id, user_id): ...@@ -335,7 +348,7 @@ def user_profile(request, course_id, user_id):
if request.is_ajax(): if request.is_ajax():
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'page': query_params['page'], 'page': query_params['page'],
'num_pages': query_params['num_pages'], 'num_pages': query_params['num_pages'],
'annotated_content_info': _attr_safe_json(annotated_content_info), 'annotated_content_info': _attr_safe_json(annotated_content_info),
...@@ -368,13 +381,30 @@ def followed_threads(request, course_id, user_id): ...@@ -368,13 +381,30 @@ def followed_threads(request, course_id, user_id):
try: try:
profiled_user = cc.User(id=user_id, course_id=course_id) profiled_user = cc.User(id=user_id, course_id=course_id)
query_params = { default_query_params = {
'page': request.GET.get('page', 1), 'page': 1,
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
'sort_key': request.GET.get('sort_key', 'date'), 'sort_key': 'date',
'sort_order': request.GET.get('sort_order', 'desc'), 'sort_order': 'desc',
} }
query_params = merge_dict(
default_query_params,
strip_none(
extract(
request.GET,
[
'page',
'sort_key',
'sort_order',
'flagged',
'unread',
'unanswered',
]
)
)
)
threads, page, num_pages = profiled_user.subscribed_threads(query_params) threads, page, num_pages = profiled_user.subscribed_threads(query_params)
query_params['page'] = page query_params['page'] = page
query_params['num_pages'] = num_pages query_params['num_pages'] = num_pages
...@@ -386,7 +416,7 @@ def followed_threads(request, course_id, user_id): ...@@ -386,7 +416,7 @@ def followed_threads(request, course_id, user_id):
is_staff = cached_has_permission(request.user, 'openclose_thread', course.id) is_staff = cached_has_permission(request.user, 'openclose_thread', course.id)
return utils.JsonResponse({ return utils.JsonResponse({
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
'discussion_data': [utils.safe_content(thread, is_staff) for thread in threads], 'discussion_data': [utils.safe_content(thread, course_id, is_staff) for thread in threads],
'page': query_params['page'], 'page': query_params['page'],
'num_pages': query_params['num_pages'], 'num_pages': query_params['num_pages'],
}) })
......
...@@ -5,6 +5,7 @@ Module for checking permissions with the comment_client backend ...@@ -5,6 +5,7 @@ Module for checking permissions with the comment_client backend
import logging import logging
from types import NoneType from types import NoneType
from django.core import cache from django.core import cache
from lms.lib.comment_client import Thread
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
CACHE = cache.get_cache('default') CACHE = cache.get_cache('default')
...@@ -34,31 +35,44 @@ def has_permission(user, permission, course_id=None): ...@@ -34,31 +35,44 @@ def has_permission(user, permission, course_id=None):
return False return False
CONDITIONS = ['is_open', 'is_author'] CONDITIONS = ['is_open', 'is_author', 'is_question_author']
def _check_condition(user, condition, course_id, data): def _check_condition(user, condition, content):
def check_open(user, condition, course_id, data): def check_open(user, content):
try: try:
return data and not data['content']['closed'] return content and not content['closed']
except KeyError: except KeyError:
return False return False
def check_author(user, condition, course_id, data): def check_author(user, content):
try: try:
return data and data['content']['user_id'] == str(user.id) return content and content['user_id'] == str(user.id)
except KeyError:
return False
def check_question_author(user, content):
if not content:
return False
try:
if content["type"] == "thread":
return content["thread_type"] == "question" and content["user_id"] == str(user.id)
else:
# N.B. This will trigger a comments service query
return check_question_author(user, Thread(id=content["thread_id"]).to_dict())
except KeyError: except KeyError:
return False return False
handlers = { handlers = {
'is_open': check_open, 'is_open': check_open,
'is_author': check_author, 'is_author': check_author,
'is_question_author': check_question_author,
} }
return handlers[condition](user, condition, course_id, data) return handlers[condition](user, content)
def _check_conditions_permissions(user, permissions, course_id, **kwargs): def _check_conditions_permissions(user, permissions, course_id, content):
""" """
Accepts a list of permissions and proceed if any of the permission is valid. Accepts a list of permissions and proceed if any of the permission is valid.
Note that ["can_view", "can_edit"] will proceed if the user has either Note that ["can_view", "can_edit"] will proceed if the user has either
...@@ -69,7 +83,7 @@ def _check_conditions_permissions(user, permissions, course_id, **kwargs): ...@@ -69,7 +83,7 @@ def _check_conditions_permissions(user, permissions, course_id, **kwargs):
def test(user, per, operator="or"): def test(user, per, operator="or"):
if isinstance(per, basestring): if isinstance(per, basestring):
if per in CONDITIONS: if per in CONDITIONS:
return _check_condition(user, per, course_id, kwargs) return _check_condition(user, per, content)
return cached_has_permission(user, per, course_id=course_id) return cached_has_permission(user, per, course_id=course_id)
elif isinstance(per, list) and operator in ["and", "or"]: elif isinstance(per, list) and operator in ["and", "or"]:
results = [test(user, x, operator="and") for x in per] results = [test(user, x, operator="and") for x in per]
...@@ -85,7 +99,7 @@ VIEW_PERMISSIONS = { ...@@ -85,7 +99,7 @@ VIEW_PERMISSIONS = {
'create_comment': [["create_comment", "is_open"]], 'create_comment': [["create_comment", "is_open"]],
'delete_thread': ['delete_thread', ['update_thread', 'is_author']], 'delete_thread': ['delete_thread', ['update_thread', 'is_author']],
'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']], 'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment': ['endorse_comment'], 'endorse_comment': ['endorse_comment', 'is_question_author'],
'openclose_thread': ['openclose_thread'], 'openclose_thread': ['openclose_thread'],
'create_sub_comment': [['create_sub_comment', 'is_open']], 'create_sub_comment': [['create_sub_comment', 'is_open']],
'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']], 'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']],
...@@ -115,4 +129,4 @@ def check_permissions_by_view(user, course_id, content, name): ...@@ -115,4 +129,4 @@ def check_permissions_by_view(user, course_id, content, name):
p = VIEW_PERMISSIONS[name] p = VIEW_PERMISSIONS[name]
except KeyError: except KeyError:
logging.warning("Permission for view named %s does not exist in permissions.py" % name) logging.warning("Permission for view named %s does not exist in permissions.py" % name)
return _check_conditions_permissions(user, p, course_id, content=content) return _check_conditions_permissions(user, p, course_id, content)
...@@ -9,7 +9,7 @@ from django.db import connection ...@@ -9,7 +9,7 @@ from django.db import connection
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import simplejson from django.utils import simplejson
from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_common.models import Role, FORUM_ROLE_STUDENT
from django_comment_client.permissions import check_permissions_by_view from django_comment_client.permissions import check_permissions_by_view, cached_has_permission
from edxmako import lookup_template from edxmako import lookup_template
import pystache_custom as pystache import pystache_custom as pystache
...@@ -258,7 +258,6 @@ def get_ability(course_id, content, user): ...@@ -258,7 +258,6 @@ def get_ability(course_id, content, user):
return { return {
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), 'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False,
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), 'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"),
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, 'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False,
'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"), 'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"),
...@@ -293,7 +292,11 @@ def get_annotated_content_infos(course_id, thread, user, user_info): ...@@ -293,7 +292,11 @@ def get_annotated_content_infos(course_id, thread, user, user_info):
def annotate(content): def annotate(content):
infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info) infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info)
for child in content.get('children', []): for child in (
content.get('children', []) +
content.get('endorsed_responses', []) +
content.get('non_endorsed_responses', [])
):
annotate(child) annotate(child)
annotate(thread) annotate(thread)
return infos return infos
...@@ -361,7 +364,7 @@ def add_courseware_context(content_list, course): ...@@ -361,7 +364,7 @@ def add_courseware_context(content_list, course):
content.update({"courseware_url": url, "courseware_title": title}) content.update({"courseware_url": url, "courseware_title": title})
def safe_content(content, is_staff=False): def safe_content(content, course_id, is_staff=False):
fields = [ fields = [
'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers', 'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at', 'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
...@@ -369,15 +372,42 @@ def safe_content(content, is_staff=False): ...@@ -369,15 +372,42 @@ def safe_content(content, is_staff=False):
'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'unread_comments_count', 'courseware_title', 'courseware_url', 'unread_comments_count',
'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers', 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers',
'stats', 'resp_skip', 'resp_limit', 'resp_total', 'stats', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
'endorsement',
] ]
if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff): if (content.get('anonymous') is False) and ((content.get('anonymous_to_peers') is False) or is_staff):
fields += ['username', 'user_id'] fields += ['username', 'user_id']
if 'children' in content: content = strip_none(extract(content, fields))
safe_children = [safe_content(child) for child in content['children']]
content['children'] = safe_children if content.get("endorsement"):
endorsement = content["endorsement"]
endorser = None
if endorsement["user_id"]:
try:
endorser = User.objects.get(pk=endorsement["user_id"])
except User.DoesNotExist:
log.error("User ID {0} in endorsement for comment {1} but not in our DB.".format(
content.get('user_id'),
content.get('id'))
)
# Only reveal endorser if requester can see author or if endorser is staff
if (
endorser and
("username" in fields or cached_has_permission(endorser, "endorse_comment", course_id))
):
endorsement["username"] = endorser.username
else:
del endorsement["user_id"]
for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
if child_content_key in content:
safe_children = [
safe_content(child, course_id, is_staff) for child in content[child_content_key]
]
content[child_content_key] = safe_children
return strip_none(extract(content, fields)) return content
...@@ -11,12 +11,12 @@ class Comment(models.Model): ...@@ -11,12 +11,12 @@ class Comment(models.Model):
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id', 'abuse_flaggers' 'type', 'commentable_id', 'abuse_flaggers', 'endorsement',
] ]
updatable_fields = [ updatable_fields = [
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed' 'user_id', 'endorsed', 'endorsement_user_id',
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields
......
...@@ -35,7 +35,7 @@ class Model(object): ...@@ -35,7 +35,7 @@ class Model(object):
return self.__getattr__(name) return self.__getattr__(name)
def __setattr__(self, name, value): def __setattr__(self, name, value):
if name == 'attributes' or name not in self.accessible_fields: if name == 'attributes' or name not in (self.accessible_fields + self.updatable_fields):
super(Model, self).__setattr__(name, value) super(Model, self).__setattr__(name, value)
else: else:
self.attributes[name] = value self.attributes[name] = value
...@@ -46,7 +46,7 @@ class Model(object): ...@@ -46,7 +46,7 @@ class Model(object):
return self.attributes.get(key) return self.attributes.get(key)
def __setitem__(self, key, value): def __setitem__(self, key, value):
if key not in self.accessible_fields: if key not in (self.accessible_fields + self.updatable_fields):
raise KeyError("Field {0} does not exist".format(key)) raise KeyError("Field {0} does not exist".format(key))
self.attributes.__setitem__(key, value) self.attributes.__setitem__(key, value)
......
...@@ -16,7 +16,8 @@ class Thread(models.Model): ...@@ -16,7 +16,8 @@ class Thread(models.Model):
'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title', 'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned',
'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total' 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
] ]
updatable_fields = [ updatable_fields = [
...@@ -29,7 +30,7 @@ class Thread(models.Model): ...@@ -29,7 +30,7 @@ class Thread(models.Model):
'endorsed', 'read' 'endorsed', 'read'
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields + ['thread_type']
base_url = "{prefix}/threads".format(prefix=settings.PREFIX) base_url = "{prefix}/threads".format(prefix=settings.PREFIX)
default_retrieve_params = {'recursive': False} default_retrieve_params = {'recursive': False}
......
...@@ -49,8 +49,15 @@ ...@@ -49,8 +49,15 @@
// applications // applications
@import "discussion/utilities/variables"; @import "discussion/utilities/variables";
@import "discussion/mixins";
@import 'discussion/discussion'; // Process old file after definitions but before everything else @import 'discussion/discussion'; // Process old file after definitions but before everything else
@import "discussion/elements/actions";
@import "discussion/elements/editor";
@import "discussion/elements/labels";
@import "discussion/elements/navigation"; @import "discussion/elements/navigation";
@import "discussion/views/thread";
@import "discussion/views/new-post";
@import "discussion/views/response";
@import 'discussion/utilities/developer'; @import 'discussion/utilities/developer';
@import 'discussion/utilities/shame'; @import 'discussion/utilities/shame';
......
...@@ -42,6 +42,9 @@ $very-light-text: #fff; ...@@ -42,6 +42,9 @@ $very-light-text: #fff;
// ==================== // ====================
// COLORS - utility
$transparent: rgba(0,0,0,0); // used when color value is needed for UI width/transitions but element is transparent
// COLORS // COLORS
$black: rgb(0,0,0); $black: rgb(0,0,0);
$black-t0: rgba($black, 0.125); $black-t0: rgba($black, 0.125);
......
// discussion - mixins and extends
// ====================
@mixin blue-button {
@include linear-gradient(top, #6dccf1, #38a8e5);
display: block;
border: 1px solid #2d81ad;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
color: $white;
text-shadow: none;
font-size: 13px;
line-height: 35px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
&:hover, &:focus {
@include linear-gradient(top, #4fbbe4, #2090d0);
border-color: #297095;
}
}
@mixin white-button {
@include linear-gradient(top, $white, $gray-l5);
display: block;
border: 1px solid #aaa;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
color: $dark-gray;
text-shadow: none;
font-size: 13px;
line-height: 35px;
&:hover, &:focus {
@include linear-gradient(top, $white, $gray-l6);
}
}
@mixin dark-grey-button {
display: block;
border: 1px solid #222;
border-radius: 3px;
padding: 0 ($baseline*.75);
height: 35px;
background: -webkit-linear-gradient(top, #777, #555);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15);
color: $white;
text-shadow: none;
font-size: 13px;
line-height: 35px;
&:hover, &:focus {
background: -webkit-linear-gradient(top, #888, #666);
}
}
@mixin discussion-wmd-input {
@include box-sizing(border-box);
margin-top: 0;
border: 1px solid #aaa;
border-radius: 3px 3px 0 0;
padding: ($baseline/2);
width: 100%;
height: 240px;
background: $white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
font-size: 13px;
font-family: 'Monaco', monospace;
line-height: 1.6;
}
@mixin discussion-wmd-preview-container {
@include box-sizing(border-box);
border: 1px solid #aaa;
border-top: none;
border-radius: 0 0 3px 3px;
width: 100%;
background: #eee;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
}
@mixin discussion-new-post-wmd-preview-container {
@include discussion-wmd-preview-container;
border-color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset;
}
@mixin discussion-wmd-preview-label {
padding-top: 3px;
padding-left: 5px;
width: 100%;
color: #bbb;
text-transform: uppercase;
font-size: 11px;
}
@mixin discussion-wmd-preview {
padding: 10px 20px;
width: 100%;
color: #333;
}
@-webkit-keyframes fadeIn {
0% { opacity: 0.0; }
100% { opacity: 1.0; }
}
// extends - content - text overflow by ellipsis
%cont-truncated {
@include box-sizing(border-box);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin forum-post-label($color) {
@extend %t-weight4;
@include font-size(9);
display: inline;
margin-top: ($baseline/4);
border: 1px solid;
border-radius: 3px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
border-color: $color;
color: $color;
.icon {
margin-right: ($baseline/5);
}
&:last-child {
margin-right: 0;
}
&.is-hidden {
display: none;
}
}
@mixin forum-user-label($color) {
@include font-size(9);
@extend %t-weight5;
vertical-align: middle;
margin-left: ($baseline/4);
border-radius: 2px;
padding: 0 ($baseline/5);
background: $color;
font-style: normal;
text-transform: uppercase;
color: white;
}
.discussion.container, .discussion-module {
// discussion - elements - actions
// ====================
// UI: general action list
.post-actions-list,
.response-actions-list,
.comment-actions-list {
@extend %ui-no-list;
text-align: right;
.actions-item {
@include box-sizing(border-box);
display: block;
margin: ($baseline/4) 0;
&.is-hidden {
display: none;
}
}
.more-wrapper {
position: relative;
}
}
// ====================
// UI: general actions dropdown layout
.actions-dropdown {
@extend %ui-no-list;
@extend %ui-depth1;
display: none;
position: absolute;
top: 100%;
right: 0;
pointer-events: none;
min-width: ($baseline*6.5);
&.is-expanded {
display: block;
pointer-events: auto;
}
.actions-dropdown-list {
@include box-sizing(border-box);
box-shadow: 0 1px 1px $shadow-l1;
position: relative;
width: 100%;
border-radius: 3px;
margin: 5px 0 0 0;
border: 1px solid $gray-l3;
padding: ($baseline/2) ($baseline*0.75);
background: $white;
// ui triangle/nub
&:after,
&:before {
bottom: 100%;
right: 3px;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
&:after {
border-color: $transparent;
border-bottom-color: $white;
border-width: 6px;
margin-right: 1px;
}
&:before {
border-color: $transparent;
border-bottom-color: $gray-l3;
border-width: 7px;
}
}
.actions-item {
display: block;
margin: 0;
&.is-hidden {
display: none;
}
}
}
// ====================
// UI: general action
.action-button {
@include transition(border .5s linear 0s);
@include box-sizing(border-box);
display: inline-block;
border: 1px solid transparent;
border-radius: 5px;
color: $gray-l1;
.action-icon {
@extend %t-icon7;
display: inline-block;
height: $baseline;
width: $baseline;
border: 1px solid $gray-l3;
border-radius: 3px;
text-align: center;
color: $gray-l1;
.icon {
vertical-align: middle;
}
}
.action-label {
@extend %t-copy-sub2;
display: inline-block;
vertical-align: middle;
padding: 0 8px;
color: $gray-l1;
opacity: 0;
}
&:hover, &:focus {
.action-label {
opacity: 1;
}
.action-icon {
border-radius: 0 3px 3px 0;
}
}
// specific button styles
&.action-follow {
.action-label {
color: $blue-d1;
}
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $forum-color-following;
border: 1px solid $blue-d1;
color: $white;
}
}
&:hover, &:focus {
border-color: $forum-color-following;
}
}
&.action-vote {
.action-label {
opacity: 1;
}
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $green-d1;
border: 1px solid $green-d2;
color: $white;
}
}
&:hover, &:focus {
border-color: $green-d2;
.action-label {
color: $green-d2;
}
}
}
&.action-endorse {
&.is-checked, &:hover, &:focus {
.action-icon {
background-color: $blue-d1;
border: 1px solid $blue-d2;
color: $white;
}
}
&:hover, &:focus {
border-color: $blue-d2;
.action-label {
color: $blue-d2;
}
}
}
&.action-answer {
&.is-checked, &:hover, &:focus {
.action-icon {
border: 1px solid $green-d1;
background-color: $green-d1;
color: $white;
}
}
&:hover, &:focus {
border-color: $green-d1;
.action-label {
color: $green-d2;
}
}
}
// more drop-down menu
&.action-more {
position: relative;
&:hover, &:focus {
border-color: $gray;
.action-icon {
border: 1px solid $gray;
background-color: $gray;
color: $white;
}
.action-label {
opacity: 1;
color: $black;
}
}
}
}
// ====================
.actions-dropdown {
// UI: secondary action
.action-list-item {
@extend %t-copy-sub2;
display: block;
padding: ($baseline/10) 0;
white-space: nowrap;
text-align: right;
color: $gray-l1;
&:hover, &:focus {
color: $link-color;
}
.action-icon {
display: inline-block;
width: ($baseline/2);
margin-left: ($baseline/4);
color: inherit;
}
.action-label {
display: inline-block;
color: inherit;
}
// CASE: checked
&.is-checked {
// CASE: pin action
&.action-pin {
color: $pink;
}
// CASE: report action
&.action-report {
color: $pink;
}
// CASE: hover for any action
&:hover, &:focus {
color: $link-color;
}
}
}
}
.action-button, .action-list-item {
.action-label {
.label-checked {
display: none;
}
}
&.is-checked {
.label-unchecked {
display: none;
}
.label-checked {
display: inline;
}
}
}
}
// discussion - elements - editor
// ====================
// UI: general editor styling
// TO-DO: isolate out all editing styling from _discussion.scss and clean up cases defined below once general syling exists
// =========================
// CASE: new post
.forum-new-post-form {
.wmd-input {
@include discussion-wmd-input;
@include box-sizing(border-box);
position: relative;
z-index: 1;
width: 100%;
height: 150px;
background: $white;
}
.wmd-preview-container {
@include discussion-new-post-wmd-preview-container;
}
.wmd-preview-label {
@include discussion-wmd-preview-label;
}
.wmd-preview {
@include discussion-wmd-preview;
}
.wmd-button {
background: none;
}
}
// =========================
// CASE: inline styling
// TO-DO: additional styling cleanup here necessary, for now this case was ported over from _discussion.scss
.discussion-module {
.wmd-panel {
width: 100%;
min-width: 500px;
}
.wmd-button-bar {
width: 100%;
}
.wmd-input {
width: 100%;
height: 150px;
border-radius: 3px 3px 0 0;
font-style: normal;
font-size: 0.8em;
font-family: Monaco, 'Lucida Console', monospace;
line-height: 1.6em;
&::-webkit-input-placeholder {
color: #888;
}
}
.wmd-button-row {
@include transition(all .2s ease-out 0s);
position: relative;
overflow: hidden;
margin: ($baseline/2) ($baseline/4) ($baseline/4) ($baseline/4);
padding: 0;
height: 30px;
}
.wmd-spacer {
position: absolute;
display: inline-block;
margin-left: 14px;
width: 1px;
height: 20px;
background-color: Silver;
list-style: none;
}
.wmd-button {
position: absolute;
display: inline-block;
padding-right: 3px;
padding-left: 2px;
width: 20px;
height: 20px;
background: none;
list-style: none;
cursor: pointer;
}
.wmd-button > span {
display: inline-block;
width: 20px;
height: 20px;
background-image: url('/static/images/wmd-buttons-transparent.png');
background-position: 0px 0px;
background-repeat: no-repeat;
}
.wmd-spacer1 {
left: 50px;
}
.wmd-spacer2 {
left: 175px;
}
.wmd-spacer3 {
left: 300px;
}
.wmd-prompt-background {
background-color: Black;
}
.wmd-prompt-dialog {
@extend .modal;
background: $white;
}
.wmd-prompt-dialog {
padding: $baseline;
> div {
font-size: 0.8em;
font-family: arial, helvetica, sans-serif;
}
b {
font-size: 16px;
}
> form > input[type="text"] {
border-radius: 3px;
color: #333;
}
> form > input[type="button"] {
border: 1px solid #888;
font-family: $sans-serif;
font-size: 14px;
}
> form > input[type="file"] {
margin-bottom: 18px;
}
}
.wmd-button-row {
// this is being hidden now because the inline styles to position the icons are not being written
position: relative;
height: 25px;
}
.wmd-button {
span {
background-image: url("/static/images/wmd-buttons.png");
display: inline-block;
}
}
}
// discussion - elements - labels
// ====================
body.discussion, .discussion-module {
.post-label-pinned {
@include forum-post-label($forum-color-pinned);
}
.post-label-following {
@include forum-post-label($forum-color-following);
}
.post-label-reported {
@include forum-post-label($forum-color-reported);
}
.post-label-closed {
@include forum-post-label($forum-color-closed);
}
.post-label-by-staff {
@include forum-post-label($forum-color-staff);
}
.post-label-by-community-ta {
@include forum-post-label($forum-color-community-ta);
}
.user-label-staff {
@include forum-user-label($forum-color-staff);
}
.user-label-community-ta {
@include forum-user-label($forum-color-community-ta);
}
}
\ No newline at end of file
// discussion - elements - navigation
// ====================
.forum-nav { .forum-nav {
@include box-sizing(border-box); @include box-sizing(border-box);
float: left; float: left;
...@@ -124,21 +127,35 @@ ...@@ -124,21 +127,35 @@
background-color: $gray-l5; background-color: $gray-l5;
padding: ($baseline/4) ($baseline/2); padding: ($baseline/4) ($baseline/2);
color: $black; color: $black;
text-align: right;
}
.forum-nav-filter-main {
@include box-sizing(border-box);
display: inline-block;
width: 50%;
text-align: left;
}
.forum-nav-filter-cohort, .forum-nav-sort {
@include box-sizing(border-box);
display: inline-block;
width: 50%;
text-align: right;
} }
%forum-nav-select { %forum-nav-select {
border: none; border: none;
max-width: 100%; max-width: 100%;
background-color: transparent; background-color: transparent;
font: inherit;
} }
.forum-nav-filter-cohort-control { .forum-nav-filter-main-control {
@extend %forum-nav-select; @extend %forum-nav-select;
} }
.forum-nav-sort { .forum-nav-filter-cohort-control {
float: right; @extend %forum-nav-select;
} }
.forum-nav-sort-control { .forum-nav-sort-control {
...@@ -176,65 +193,41 @@ ...@@ -176,65 +193,41 @@
vertical-align: middle; vertical-align: middle;
} }
.forum-nav-thread-wrapper-1 { .forum-nav-thread-wrapper-0 {
@extend %forum-nav-thread-wrapper;
width: 70%;
}
.forum-nav-thread-wrapper-2 {
@extend %forum-nav-thread-wrapper; @extend %forum-nav-thread-wrapper;
width: 30%; width: 7%;
text-align: right;
}
.forum-nav-thread-title {
@extend %t-title7;
display: block;
}
%forum-nav-thread-label { .icon {
@extend %t-weight4; @include font-size(14);
@include font-size(9);
display: inline;
margin-top: ($baseline/4);
border: 1px solid;
border-radius: 3px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
&:last-child {
margin-right: 0;
} }
.icon { .icon-comments {
margin-right: ($baseline/5); color: $gray-l2;
} }
} .icon-ok {
color: $forum-color-marked-answer;
}
.forum-nav-thread-label-pinned { .icon-question {
@extend %forum-nav-thread-label; color: $pink;
border-color: $forum-color-pinned; }
color: $forum-color-pinned;
} }
.forum-nav-thread-label-following { .forum-nav-thread-wrapper-1 {
@extend %forum-nav-thread-label; @extend %forum-nav-thread-wrapper;
border-color: $forum-color-following; width: 80%;
color: $forum-color-following;
} }
.forum-nav-thread-label-staff { .forum-nav-thread-wrapper-2 {
@extend %forum-nav-thread-label; @extend %forum-nav-thread-wrapper;
border-color: $forum-color-staff; width: 13%;
color: $forum-color-staff; text-align: right;
} }
.forum-nav-thread-label-community-ta { .forum-nav-thread-title {
@extend %forum-nav-thread-label; @extend %t-title7;
border-color: $forum-color-community-ta; display: block;
color: $forum-color-community-ta;
} }
%forum-nav-thread-wrapper-2-content { %forum-nav-thread-wrapper-2-content {
...@@ -249,11 +242,6 @@ ...@@ -249,11 +242,6 @@
} }
} }
.forum-nav-thread-endorsed {
@extend %forum-nav-thread-wrapper-2-content;
color: $green-d1;
}
.forum-nav-thread-votes-count { .forum-nav-thread-votes-count {
@extend %forum-nav-thread-wrapper-2-content; @extend %forum-nav-thread-wrapper-2-content;
} }
......
...@@ -66,9 +66,16 @@ ...@@ -66,9 +66,16 @@
// navigation - sort and filter bar // navigation - sort and filter bar
// -------------------------------- // --------------------------------
// Override global span rules // Override global label rules
.forum-nav-sort-label { .forum-nav-filter-main, .forum-nav-filter-cohort, .forum-nav-sort {
color: inherit; font: inherit;
line-height: 1em;
margin-bottom: 0;
}
// Override global select rules
.forum-nav-filter-main-control, .forum-nav-filter-cohort-control, .forum-nav-sort-control {
font: inherit;
} }
// -------------------------------- // --------------------------------
...@@ -95,3 +102,55 @@ li[class*=forum-nav-thread-label-] { ...@@ -95,3 +102,55 @@ li[class*=forum-nav-thread-label-] {
display: none !important; display: none !important;
} }
} }
// -------------
// new post form
// -------------
.forum-new-post-form {
// Override global label rules
.post-type {
text-shadow: none;
}
.post-type, .topic-filter-label {
margin-bottom: 0;
}
// Override global ul rules
.topic-menu {
padding-left: 0;
}
.topic-menu, .topic-submenu {
margin-top: 0;
margin-bottom: 0;
}
// Override global span rules
.post-topic-button .drop-arrow {
line-height: 36px;
}
.topic-title {
line-height: 14px;
}
}
// -------
// Actions
// -------
.discussion.container, .discussion-module {
// Override courseware
.post-actions-list, .response-actions-list, .comment-actions-list {
@extend %t-copy-sub2;
padding-left: 0 !important;
}
// Override global span
.action-label span, .action-icon span {
color: inherit;
}
}
$forum-color-active-thread: tint($blue, 85%); $forum-color-active-thread: tint($blue, 85%);
$forum-color-pinned: $pink; $forum-color-pinned: $pink;
$forum-color-reported: $pink;
$forum-color-closed: $black;
$forum-color-following: $blue; $forum-color-following: $blue;
$forum-color-staff: $blue; $forum-color-staff: $blue;
$forum-color-community-ta: $green-d1; $forum-color-community-ta: $green-d1;
$forum-color-marked-answer: $green-d1;
// discussion - views - new post
// ====================
// UI: form structure
.forum-new-post-form {
@include clearfix;
box-sizing: border-box;
margin: 0;
border-radius: 3px;
padding: ($baseline*2);
min-width: 760px;
max-width: 1180px;
background: $gray-l5;
.post-field {
margin-bottom: $baseline;
.field-label {
display: inline-block;
width: 50%;
vertical-align: top;
line-height: 40px;
.field-input {
display: inline-block;
width: 100%;
vertical-align: top;
}
.field-label-text {
display: inline-block;
width: 25%;
vertical-align: top;
text-transform: uppercase;
font-size: 12px;
line-height: 40px;
}
.field-label-text + .field-input {
width: 75%;
}
}
// UI: support text for input fields
.field-help {
@include box-sizing(border-box);
display: inline-block;
padding-left: $baseline;
width: 50%;
font-size: 12px;
}
}
.post-options {
margin-bottom: ($baseline/2);
}
}
// CASE: inline styling
.discussion-module .forum-new-post-form {
background: $white;
}
// ====================
// UI: inputs
.forum-new-post-form {
.post-topic-button {
@include white-button;
@extend %cont-truncated;
z-index: 1000;
padding: 0 $baseline 0 ($baseline*.75);
height: 40px;
font-size: 14px;
line-height: 36px;
.drop-arrow {
float: right;
color: #999;
}
}
.post-type-input {
@extend %text-sr;
}
.post-type-label {
@extend %cont-truncated;
@include box-sizing(border-box);
@include white-button;
@include font-size(14);
display: inline-block;
padding: 0 ($baseline/2);
width: 48%;
height: 40px;
text-align: center;
color: $gray-d3;
font-weight: 600;
line-height: 36px;
.icon {
margin-right: 5px;
}
}
.post-type-input:checked + .post-type-label {
background-color: $forum-color-active-thread;
background-image: none;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset;
}
.post-type-input:focus + .post-type-label {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset, 0 0 2px 2px $blue;
}
input[type=text].field-input {
@include box-sizing(border-box);
border: 1px solid $gray-l2;
border-radius: 3px;
padding: 0 $baseline/2;
height: 40px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset;
color: #333;
font-weight: 700;
font-size: 16px;
font-family: 'Open Sans', sans-serif;
}
.post-option {
@include box-sizing(border-box);
display: inline-block;
margin-right: $baseline;
border: 1px solid transparent;
border-radius: 3px;
padding: ($baseline/2);
&:hover {
border-color: $gray-l3;
}
&.is-enabled {
border-color: $blue;
color: $blue;
}
.post-option-input {
margin-right: ($baseline/2);
}
.icon {
margin-right: 0.5em;
}
}
}
// ====================
// UI: actions
.forum-new-post-form {
.submit {
@include blue-button;
display: inline-block;
margin-right: ($baseline/2);
}
.cancel {
@include white-button;
display: inline-block;
}
}
// ====================
// UI: errors - new post creation
.forum-new-post-form {
.post-errors {
margin-bottom: $baseline;
border-radius: 3px;
padding: 0;
background: $error-red;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset, 0 1px 0 rgba(255, 255, 255, .2);
color: $white;
list-style: none;
.post-error {
padding: ($baseline/2) $baseline 12px 45px;
border-bottom: 1px solid $red;
background: url(../images/white-error-icon.png) no-repeat 15px 14px;
&:last-child {
border-bottom: none;
}
}
}
}
// ====================
// UI: topic menu
// TO-DO: refactor to use _navigation.scss as general topic selector
.forum-new-post-form .post-topic {
position: relative;
.topic-menu-wrapper {
@include box-sizing(border-box);
position: absolute;
top: 40px;
left: 0;
z-index: 9999;
border: 1px solid $gray-l3;
width: 100%;
background: $white;
box-shadow: 0 2px 1px $shadow;
}
.topic-filter-label {
border-bottom: 1px solid $gray-l2;
padding: ($baseline/4);
}
.topic-filter-input {
@include box-sizing(border-box);
border: 1px solid $gray-l3;
padding: 0 15px;
width: 100%;
height: 30px;
color: #333;
font-size: 11px;
line-height: 16px;
}
.topic-menu {
overflow-y: scroll;
max-height: 400px;
list-style: none;
}
.topic-submenu {
padding-left: $baseline;
list-style: none;
}
.topic-title {
display: block;
border-bottom: 1px solid $gray-l3;
padding: ($baseline/2);
font-size: 14px;
}
a.topic-title {
@include transition(none);
&:hover, &:focus {
background-color: $gray-l4;
}
}
}
.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 @@ ...@@ -14,7 +14,7 @@
<%def name="render_entry(entries, entry)"> <%def name="render_entry(entries, entry)">
<li <li
class="forum-nav-browse-menu-item" 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()}" data-cohorted="${str(entries[entry]['is_cohorted']).lower()}"
> >
<a href="#" class="forum-nav-browse-title">${entry}</a> <a href="#" class="forum-nav-browse-title">${entry}</a>
...@@ -42,11 +42,6 @@ ...@@ -42,11 +42,6 @@
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all"> <li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">${_("All Discussions")}</a> <a href="#" class="forum-nav-browse-title">${_("All Discussions")}</a>
</li> </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"> <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> <a href="#" class="forum-nav-browse-title"><i class="icon icon-star"></i>${_("Posts I'm Following")}</a>
</li> </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 @@ ...@@ -20,18 +20,41 @@
<%include file="_filter_dropdown.html" /> <%include file="_filter_dropdown.html" />
<div class="forum-nav-thread-list-wrapper"> <div class="forum-nav-thread-list-wrapper">
<div class="forum-nav-refine-bar"> <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: %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"> <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: %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 %endfor
</select> </select>
</span> </label>\
%endif %endif
## Lack of indentation is intentional to avoid whitespace between this and siblings
<span class="forum-nav-sort"> <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"> <select class="forum-nav-sort-control">
## Translators: This is a menu option for sorting forum threads ## Translators: This is a menu option for sorting forum threads
<option value="date">${_("by recent activity")}</option> <option value="date">${_("by recent activity")}</option>
...@@ -40,7 +63,7 @@ ...@@ -40,7 +63,7 @@
## Translators: This is a menu option for sorting forum threads ## Translators: This is a menu option for sorting forum threads
<option value="votes">${_("by most votes")}</option> <option value="votes">${_("by most votes")}</option>
</select> </select>
</span> </label>
</div> </div>
<div class="search-alerts"></div> <div class="search-alerts"></div>
<ul class="forum-nav-thread-list"></ul> <ul class="forum-nav-thread-list"></ul>
......
...@@ -23,8 +23,6 @@ ...@@ -23,8 +23,6 @@
<%include file="_discussion_course_navigation.html" args="active_page='discussion'" /> <%include file="_discussion_course_navigation.html" args="active_page='discussion'" />
<article class="new-post-article"></article>
<section class="discussion container" id="discussion-container" <section class="discussion container" id="discussion-container"
data-roles="${roles}" data-roles="${roles}"
data-course-id="${course_id}" data-course-id="${course_id}"
...@@ -39,6 +37,8 @@ ...@@ -39,6 +37,8 @@
<div class="discussion-body"> <div class="discussion-body">
<div class="forum-nav"></div> <div class="forum-nav"></div>
<div class="discussion-column"> <div class="discussion-column">
<article class="new-post-article" style="display: none"></article>
<div class="forum-content"></div>
</div> </div>
</div> </div>
</section> </section>
......
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