Commit 988e4e6d by Greg Price

Update UI for forum actions

The actions are now consolidated in one location for each piece of
content. Primary actions (vote, follow, endorse, mark as answer) are
buttons, and secondary actions (pin, edit, delete, report, close) are in
a menu. This also includes improved front-end error handling for the
actions and significant test cleanup.

Co-authored-by: jsa <jsa@edx.org>
Co-authored-by: marco <marcotuts@gmail.com>
Co-authored-by: Frances Botsford <frances@edx.org>
Co-authored-by: Brian Talbot <btalbot@edx.org>
parent a99196a1
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 "DiscussionThreadView", -> describe "DiscussionThreadView", ->
beforeEach -> beforeEach ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
setFixtures( DiscussionSpecHelper.setUnderscoreFixtures()
"""
<script type="text/template" id="thread-template">
<article class="discussion-article">
<div class="forum-thread-main-wrapper">
<div class="thread-content-wrapper"></div>
<div class="post-extended-content">
<ol class="responses js-marked-answer-list"></ol>
</div>
</div>
<div class="post-extended-content">
<div class="response-count"></div>
<ol class="responses js-response-list"></ol>
<div class="response-pagination"></div>
</div>
<div class="post-tools">
<a href="javascript:void(0)" class="forum-thread-expand">Expand</a>
<a href="javascript:void(0)" class="forum-thread-collapse">Collapse</a>
</div>
</article>
</script>
<script type="text/template" id="thread-show-template">
<div class="discussion-post">
<div class="post-body"><%- body %></div>
</div>
</script>
<script type="text/template" id="thread-response-template">
<div class="response"></div>
</script>
<div class="thread-fixture"/>
"""
)
jasmine.Clock.useMock() jasmine.Clock.useMock()
@threadData = DiscussionViewSpecHelper.makeThreadWithProps({}) @threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
...@@ -73,7 +42,7 @@ describe "DiscussionThreadView", -> ...@@ -73,7 +42,7 @@ describe "DiscussionThreadView", ->
describe "tab mode", -> describe "tab mode", ->
beforeEach -> beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $(".thread-fixture"), mode: "tab"}) @view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "tab"})
describe "response count and pagination", -> describe "response count and pagination", ->
it "correctly render for a thread with no responses", -> it "correctly render for a thread with no responses", ->
...@@ -114,7 +83,7 @@ describe "DiscussionThreadView", -> ...@@ -114,7 +83,7 @@ describe "DiscussionThreadView", ->
describe "inline mode", -> describe "inline mode", ->
beforeEach -> beforeEach ->
@view = new DiscussionThreadView({ model: @thread, el: $(".thread-fixture"), mode: "inline"}) @view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "inline"})
describe "render", -> describe "render", ->
it "shows content that should be visible when collapsed", -> it "shows content that should be visible when collapsed", ->
...@@ -159,7 +128,7 @@ describe "DiscussionThreadView", -> ...@@ -159,7 +128,7 @@ describe "DiscussionThreadView", ->
beforeEach -> beforeEach ->
@thread.set("thread_type", "question") @thread.set("thread_type", "question")
@view = new DiscussionThreadView( @view = new DiscussionThreadView(
{model: @thread, el: $(".thread-fixture"), mode: "tab"} {model: @thread, el: $("#fixture-element"), mode: "tab"}
) )
renderTestCase = (view, numEndorsed, numNonEndorsed) -> renderTestCase = (view, numEndorsed, numNonEndorsed) ->
...@@ -173,8 +142,8 @@ describe "DiscussionThreadView", -> ...@@ -173,8 +142,8 @@ describe "DiscussionThreadView", ->
non_endorsed_resp_total: numNonEndorsed non_endorsed_resp_total: numNonEndorsed
} }
) )
expect(view.$(".js-marked-answer-list .response").length).toEqual(numEndorsed) expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed)
expect(view.$(".js-response-list .response").length).toEqual(numNonEndorsed) expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed)
assertResponseCountAndPaginationCorrect( assertResponseCountAndPaginationCorrect(
view, view,
ngettext( ngettext(
...@@ -209,8 +178,8 @@ describe "DiscussionThreadView", -> ...@@ -209,8 +178,8 @@ describe "DiscussionThreadView", ->
non_endorsed_resp_total: 41 non_endorsed_resp_total: 41
}) })
@view.$el.find(".load-response-button").click() @view.$el.find(".load-response-button").click()
expect($(".js-marked-answer-list .response").length).toEqual(3) expect($(".js-marked-answer-list .discussion-response").length).toEqual(3)
expect($(".js-response-list .response").length).toEqual(6) expect($(".js-response-list .discussion-response").length).toEqual(6)
assertResponseCountAndPaginationCorrect( assertResponseCountAndPaginationCorrect(
@view, @view,
"41 other responses", "41 other responses",
......
...@@ -10,107 +10,54 @@ class @DiscussionViewSpecHelper ...@@ -10,107 +10,54 @@ class @DiscussionViewSpecHelper
unread_comments_count: 0, unread_comments_count: 0,
comments_count: 0, comments_count: 0,
abuse_flaggers: [], abuse_flaggers: [],
body: "" body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
} }
$.extend(thread, props) $.extend(thread, props)
@expectVoteRendered = (view, voted) -> @expectVoteRendered = (view, model, user) ->
button = view.$el.find(".vote-btn") button = view.$el.find(".action-vote")
if voted expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.hasClass("is-cast")).toBe(true) expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.attr("aria-pressed")).toEqual("true") expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.attr("data-tooltip")).toEqual("remove vote") expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$")
expect(button.text()).toEqual("43 votes (click to remove your vote)")
else
expect(button.hasClass("is-cast")).toBe(false)
expect(button.attr("aria-pressed")).toEqual("false")
expect(button.attr("data-tooltip")).toEqual("vote")
expect(button.text()).toEqual("42 votes (click to vote)")
@checkRenderVote = (view, model) -> @checkRenderVote = (view, model) ->
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model) window.user.vote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, true) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model) window.user.unvote(model)
view.renderVote() view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, false) DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "43"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
)
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
@checkUnvote = (view, model, modelData, checkRendering) ->
window.user.vote(model)
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").andCallFake((params) => spyOn($, "ajax").andCallFake((params) =>
newModelData = {} expect(params.url.toString()).toEqual(expectedUrl)
$.extend(newModelData, modelData, {votes: {up_count: "42"}}) return deferred
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
) )
view.render()
view.unvote() view.$el.find(".action-vote").trigger(event)
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled() expect($.ajax).toHaveBeenCalled()
deferred.resolve()
@checkToggleVote = (view, model) ->
event = {preventDefault: ->} @checkUpvote = (view, model, user, event) ->
spyOn(event, "preventDefault") expect(model.id in user.get('upvoted_ids')).toBe(false)
spyOn(view, "vote").andCallFake(() -> window.user.vote(model)) initialVoteCount = model.get('votes').up_count
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model)) triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(window.user.voted(model)).toBe(false) expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
view.toggleVote(event)
expect(view.vote).toHaveBeenCalled() @checkUnvote = (view, model, user, event) ->
expect(view.unvote).not.toHaveBeenCalled() user.vote(model)
expect(event.preventDefault.callCount).toEqual(1) expect(model.id in user.get('upvoted_ids')).toBe(true)
initialVoteCount = model.get('votes').up_count
view.vote.reset() triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
view.unvote.reset() expect(user.get('upvoted_ids')).toEqual([])
expect(window.user.voted(model)).toBe(true) expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
view.toggleVote(event)
expect(view.vote).not.toHaveBeenCalled()
expect(view.unvote).toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(2)
@checkButtonEvents = (view, viewFunc, buttonSelector) -> @checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc) spy = spyOn(view, viewFunc)
...@@ -126,7 +73,7 @@ class @DiscussionViewSpecHelper ...@@ -126,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 ->
DiscussionSpecHelper.setUpGlobals() DiscussionSpecHelper.setUpGlobals()
setFixtures """ DiscussionSpecHelper.setUnderscoreFixtures()
<script id="thread-response-template" type="text/template">
<a href="#" class="action-show-comments">Show comments</a>
<ol class="comments"></ol>
</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")
...@@ -24,7 +19,7 @@ describe 'ThreadResponseView', -> ...@@ -24,7 +19,7 @@ describe 'ThreadResponseView', ->
it 'hides "show comments" link if collapseComments is set but response has no comments', -> it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [] } @response = new Comment { children: [] }
@view = new ThreadResponseView({ @view = new ThreadResponseView({
model: @response, el: $("#thread-response-fixture"), model: @response, el: $("#fixture-element"),
collapseComments: true collapseComments: true
}) })
@view.render() @view.render()
...@@ -33,7 +28,7 @@ describe 'ThreadResponseView', -> ...@@ -33,7 +28,7 @@ describe 'ThreadResponseView', ->
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', -> it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({ @view = new ThreadResponseView({
model: @response, el: $("#thread-response-fixture"), model: @response, el: $("#fixture-element"),
collapseComments: true collapseComments: true
}) })
@view.render() @view.render()
......
...@@ -108,13 +108,21 @@ if Backbone? ...@@ -108,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:
......
...@@ -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
...@@ -162,6 +161,13 @@ class @DiscussionUtil ...@@ -162,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(' ')
......
if Backbone? if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView class @DiscussionThreadShowView extends DiscussionContentShowView
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: (options) -> initialize: (options) ->
super() super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline" @mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"] if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode) throw new Error("invalid mode: " + @mode)
@model.on "change", @updateModelDetails
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-show-template").html()) @template = _.template($("#thread-show-template").html())
context = @model.toJSON() context = $.extend(
context.mode = @mode {
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid
},
@model.attributes,
)
@template(context) @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()
...@@ -49,60 +29,6 @@ if Backbone? ...@@ -49,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()
...@@ -114,64 +40,6 @@ if Backbone? ...@@ -114,64 +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>"))
...@@ -52,6 +52,12 @@ if Backbone? ...@@ -52,6 +52,12 @@ if Backbone?
else # mode == "inline" else # mode == "inline"
@collapse() @collapse()
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@renderAddResponseButton()
})
expand: (event) -> expand: (event) ->
if event if event
event.preventDefault() event.preventDefault()
...@@ -200,8 +206,8 @@ if Backbone? ...@@ -200,8 +206,8 @@ if Backbone?
@$el.find(listSelector).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()
...@@ -215,9 +221,8 @@ if Backbone? ...@@ -215,9 +221,8 @@ if Backbone?
addComment: => addComment: =>
@model.comment() @model.comment()
endorseThread: (endorsed) => endorseThread: =>
is_endorsed = @$el.find(".is-endorsed").length > 0 @model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
@model.set 'endorsed', is_endorsed
submitComment: (event) -> submitComment: (event) ->
event.preventDefault() event.preventDefault()
......
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))
@delegateEvents() @delegateEvents()
@renderAttrs() @renderAttrs()
@renderFlagged()
@markAsStaff()
@$el.find(".timeago").timeago() @$el.find(".timeago").timeago()
@convertMath() @convertMath()
@addReplyLink() @addReplyLink()
...@@ -51,31 +35,8 @@ if Backbone? ...@@ -51,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)
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
endorsed: (endorsed) ->
$endorseButton = @$(".action-endorse")
$endorseButton.toggleClass("is-clickable", @model.canBeEndorsed())
$endorseButton.toggleClass("is-endorsed", endorsed)
$endorseButton.toggle(endorsed || @model.canBeEndorsed())
})
$: (selector) ->
@$el.find(selector)
initialize: -> initialize: ->
super() super()
@listenTo(@model, "change", @render) @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").timeago()
@convertMath() @convertMath()
@markAsStaff()
@ @
convertMath: -> convertMath: ->
...@@ -47,58 +29,8 @@ if Backbone? ...@@ -47,58 +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.canBeEndorsed()
return
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
new_endorsed = not endorsed
data = { endorsed: new_endorsed }
endorsement = {
"username": window.user.get("username"),
"time": new Date().toISOString()
}
@model.set(
"endorsed": new_endorsed
"endorsement": if new_endorsed then endorsement else null
)
@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"))
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,11 +110,12 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -89,11 +110,12 @@ 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)):
EmptyPromise( self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click()
lambda: self.is_response_editor_visible(response_id), EmptyPromise(
"Response edit started" lambda: self.is_response_editor_visible(response_id),
).fulfill() "Response edit started"
).fulfill()
def is_show_comments_visible(self, response_id): def is_show_comments_visible(self, response_id):
"""Returns true if the "show comments" link is visible for a response""" """Returns true if the "show comments" link is visible for a response"""
...@@ -120,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -120,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"
...@@ -132,7 +156,8 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -132,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"""
...@@ -144,15 +169,16 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): ...@@ -144,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"""
......
...@@ -51,10 +51,12 @@ ...@@ -51,10 +51,12 @@
@import "discussion/utilities/variables"; @import "discussion/utilities/variables";
@import "discussion/mixins"; @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/views/new-post"; @import "discussion/elements/actions";
@import "discussion/elements/editor"; @import "discussion/elements/editor";
@import "discussion/elements/labels";
@import "discussion/elements/navigation"; @import "discussion/elements/navigation";
@import "discussion/views/thread"; @import "discussion/views/thread";
@import "discussion/views/new-post";
@import "discussion/views/response"; @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);
......
// forums - main app styling // forums - main app styling
// ==================== // ====================
body.discussion { body.discussion {
.course-tabs .right { .course-tabs .right {
...@@ -423,7 +422,7 @@ body.discussion { ...@@ -423,7 +422,7 @@ body.discussion {
} }
h1 { h1 {
margin-bottom: $baseline/2; margin-bottom: ($baseline/4);
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
letter-spacing: 0; letter-spacing: 0;
...@@ -432,18 +431,14 @@ body.discussion { ...@@ -432,18 +431,14 @@ body.discussion {
.posted-details { .posted-details {
font-size: 12px; font-size: 12px;
font-style: italic;
color: #888; color: #888;
.username { .username {
display: block;
font-size: 16px;
font-weight: 700; font-weight: 700;
} }
.timeago, .top-post-status { .timeago, .top-post-status {
color: inherit; color: inherit;
font-style: italic;
} }
} }
...@@ -456,37 +451,6 @@ body.discussion { ...@@ -456,37 +451,6 @@ body.discussion {
p + p { p + p {
margin-top: $baseline; margin-top: $baseline;
} }
.dogear {
display: block;
position: absolute;
top: -1px;
right: -1px;
width: 52px;
height: 51px;
background: url(../images/follow-dog-ear.png) 0 -52px no-repeat;
@include transition(none);
&.is-followed {
background-position: 0 0;
}
}
}
.discussion-post {
padding: ($baseline*2) ($baseline*2) 0 ($baseline*2);
> header .vote-btn {
position: relative;
z-index: 100;
margin-top: ($baseline/4);
margin-left: ($baseline*2);
}
.post-tools {
@include clearfix;
margin-top: 15px;
}
} }
.discussion-post header, .discussion-post header,
...@@ -565,7 +529,7 @@ body.discussion { ...@@ -565,7 +529,7 @@ body.discussion {
.discussion-response { .discussion-response {
@include box-sizing(border-box); @include box-sizing(border-box);
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
padding: $baseline $baseline 0; padding: $baseline;
background-color: $white; background-color: $white;
} }
.posted-by { .posted-by {
...@@ -594,94 +558,6 @@ body.discussion { ...@@ -594,94 +558,6 @@ body.discussion {
} }
} }
.vote-btn {
position: relative;
z-index: 100;
float: right;
display: block;
height: 27px;
padding: 0 8px;
border-radius: 5px;
border: 1px solid #b2b2b2;
@include linear-gradient(top, $white 35%, #ebebeb);
box-shadow: 0 1px 1px rgba(0, 0, 0, .15);
font-size: 12px;
font-weight: 700;
line-height: 25px;
color: #333;
.plus-icon {
display: inline-block;
width: 10px;
height: 10px;
margin: 8px 6px 0 0;
background: url(../images/vote-plus-icon.png) no-repeat;
font-size: 18px;
text-indent: -9999px;
color: #17b429;
overflow: hidden;
}
&.is-cast {
border-color: #379a42;
@include linear-gradient(top, #50cc5e, #3db84b);
color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, .3);
box-shadow: 0 1px 0 rgba(255, 255, 255, .4) inset, 0 1px 2px $shadow;
.plus-icon {
background-position: 0 -10px;
color: #336a39;
text-shadow: 0 1px 0 rgba(255, 255, 255, .4);
}
}
}
.endorse-btn {
display: block;
float: right;
width: 27px;
height: 27px;
margin-right: ($baseline/2);
border-radius: 27px;
border: 1px solid #a0a0a0;
@include linear-gradient(top, $white 35%, $gray-l4);
box-shadow: 0 1px 1px $shadow-l1;
cursor: default;
&.is-clickable {
cursor: auto;
}
.check-icon {
display: block;
width: 13px;
height: 12px;
margin: 8px auto;
background: url(../images/endorse-icon.png) no-repeat;
pointer-events: none;
}
&.mark-answer .check-icon {
background: url(../images/answer-icon.png) no-repeat;
}
&.is-endorsed {
border: 1px solid #4697c1;
@include linear-gradient(top, #6dccf1, #38a8e5);
box-shadow: 0 1px 1px $shadow-l1, 0 1px 0 rgba(255, 255, 255, .4) inset;
.check-icon {
background-position: 0 -12px;
}
&.mark-answer {
@include linear-gradient(top, tint(#1d9348, 60%), tint(#1d9348, 20%));
border: 1px solid #1d9348;
}
}
}
blockquote { blockquote {
background: $gray-l5; background: $gray-l5;
border-radius: 3px; border-radius: 3px;
...@@ -689,89 +565,6 @@ body.discussion { ...@@ -689,89 +565,6 @@ body.discussion {
font-size: 14px; font-size: 14px;
} }
.comments {
margin: 0;
border-radius: 0 0 3px 3px;
padding: 0;
background: $gray-l6;
box-shadow: 0 1px 3px -1px $shadow inset;
list-style: none;
> li {
border-top: 1px solid $gray-l4;
padding: ($baseline/2) $baseline;
}
blockquote {
background: $gray-l4;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
font-size: 14px;
}
.comment-form {
@include clearfix;
.comment-form-input {
padding: ($baseline/4) ($baseline/2);
background-color: $white;
font-size: 14px;
}
.discussion-submit-comment {
@include blue-button;
float: left;
margin-top: 8px;
}
.wmd-input {
height: 40px;
}
.discussion-errors {
margin: 0;
}
}
.response-body {
font-size: 13px;
margin-bottom: ($baseline/2);
p + p {
margin-top: 12px;
}
}
.posted-details {
font-size: 11px;
}
.staff-label {
margin-left: ($baseline/10);
padding: 0 ($baseline/5);
border-radius: 2px;
background: #009FE2;
font-size: 9px;
font-weight: 700;
font-style: normal;
color: white;
text-transform: uppercase;
}
}
.community-ta-label{
margin-left: ($baseline/10);
padding: 0 ($baseline/5);
border-radius: 2px;
background: $forum-color-community-ta;
font-size: 9px;
font-weight: 700;
font-style: normal;
color: white;
text-transform: uppercase;
}
.comment-form { .comment-form {
padding: ($baseline/2) 0; padding: ($baseline/2) 0;
...@@ -803,51 +596,6 @@ body.discussion { ...@@ -803,51 +596,6 @@ body.discussion {
} }
} }
.moderator-actions {
margin: 0;
padding: $baseline 0;
@include clearfix;
li {
float: left;
margin-right: ($baseline/2);
list-style: none;
}
a {
@include white-button;
height: 26px;
@include linear-gradient(top, $white 35%, #ebebeb);
font-size: 13px;
line-height: 24px;
color: #737373;
font-weight: normal;
box-shadow: 0 1px 1px $shadow-l1;
&:hover, &:focus {
@include linear-gradient(top, $white 35%, #ddd);
}
.delete-icon {
display: block;
float: left;
width: 10px;
height: 10px;
margin: 8px 4px 0 0;
background: url(../images/moderator-delete-icon.png) no-repeat;
}
.edit-icon {
display: block;
float: left;
width: 10px;
height: 10px;
margin: 7px 4px 0 0;
background: url(../images/moderator-edit-icon.png) no-repeat;
}
}
}
.main-article.new { .main-article.new {
display: none; display: none;
padding: ($baseline*2.5); padding: ($baseline*2.5);
...@@ -900,16 +648,6 @@ body.discussion { ...@@ -900,16 +648,6 @@ body.discussion {
// ==================== // ====================
// post actions -global
.global-discussion-actions {
height: 60px;
@include linear-gradient(top, #ebebeb, #d9d9d9);
border-radius: 0 3px 0 0;
border-bottom: 1px solid #bcbcbc;
}
// ====================
// inline discussion module and profile thread styling // inline discussion module and profile thread styling
.discussion-module { .discussion-module {
@extend .discussion-body; @extend .discussion-body;
...@@ -993,16 +731,6 @@ body.discussion { ...@@ -993,16 +731,6 @@ body.discussion {
margin-bottom: $baseline; margin-bottom: $baseline;
@include transition(all .25s linear 0s); @include transition(all .25s linear 0s);
.dogear {
display: none;
}
&.expanded {
.dogear{
display: block;
}
}
p { p {
margin-bottom: 0; margin-bottom: 0;
} }
...@@ -1174,10 +902,6 @@ body.discussion { ...@@ -1174,10 +902,6 @@ body.discussion {
color: $white; color: $white;
} }
.moderator-actions {
padding-left: 0 !important;
}
section.pagination { section.pagination {
margin-top: 30px; margin-top: 30px;
...@@ -1260,99 +984,6 @@ body.discussion { ...@@ -1260,99 +984,6 @@ body.discussion {
} }
} }
// post actions - pinning
.discussion-pin {
font-size: 12px;
float:right;
padding-right: 5px;
font-style: italic;
margin-right: $baseline/2;
opacity: 0.8;
&.admin-pin {
cursor: pointer;
&:hover, &:focus {
@include transition(opacity .2s linear 0s);
opacity: 1.0;
}
}
}
.discussion-pin-inline {
font-size: 12px;
float:right;
font-style: italic;
position: relative;
right:-20px;
top:-13px;
margin-right:35px;
margin-top:13px;
opacity: 1.0;
}
.notpinned .icon {
display: block;
float: left;
margin: 3px;
width: 10px;
height: 14px;
padding-right: 3px;
color: #333;
}
.pinned .icon {
display: block;
float: left;
margin: 3px;
width: 10px;
height: 14px;
padding-right: 3px;
color: $pink;
}
.pinned span {
color: $pink;
font-style: italic;
}
.notpinned span {
color: #333;
font-style: italic;
}
.pinned-false
{
display:none;
}
// ====================
// post actions - flagging
.discussion-flag-abuse, .discussion-delete-comment, .discussion-edit-comment {
font-size: 12px;
float:right;
margin-left: ($baseline/2);
font-style: italic;
cursor:pointer;
color: $dark-gray;
opacity: 0.8;
&:hover, &:focus {
@include transition(opacity .2s linear 0s);
opacity: 1.0;
}
.flag-label {
font-style: italic;
margin-left: ($baseline/4);
}
}
.flagged * {
color: $pink;
}
// ==================== // ====================
// post pagination // post pagination
......
...@@ -113,4 +113,44 @@ ...@@ -113,4 +113,44 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
\ No newline at end of file
@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 - 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
...@@ -230,51 +230,6 @@ ...@@ -230,51 +230,6 @@
display: block; display: block;
} }
%forum-nav-thread-label {
@extend %t-weight4;
@include font-size(9);
display: inline;
margin-top: ($baseline/4);
border: 1px solid;
border-radius: 3px;
padding: 1px 6px;
text-transform: uppercase;
white-space: nowrap;
&:last-child {
margin-right: 0;
}
.icon {
margin-right: ($baseline/5);
}
}
.forum-nav-thread-label-pinned {
@extend %forum-nav-thread-label;
border-color: $forum-color-pinned;
color: $forum-color-pinned;
}
.forum-nav-thread-label-following {
@extend %forum-nav-thread-label;
border-color: $forum-color-following;
color: $forum-color-following;
}
.forum-nav-thread-label-staff {
@extend %forum-nav-thread-label;
border-color: $forum-color-staff;
color: $forum-color-staff;
}
.forum-nav-thread-label-community-ta {
@extend %forum-nav-thread-label;
border-color: $forum-color-community-ta;
color: $forum-color-community-ta;
}
%forum-nav-thread-wrapper-2-content { %forum-nav-thread-wrapper-2-content {
@include font-size(11); @include font-size(11);
display: inline-block; display: inline-block;
......
...@@ -136,3 +136,21 @@ li[class*=forum-nav-thread-label-] { ...@@ -136,3 +136,21 @@ li[class*=forum-nav-thread-label-] {
line-height: 14px; 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;
......
// 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 { .forum-thread-main-wrapper {
border-bottom: 1px solid $white; // Prevent collapsing margins border-bottom: 1px solid $white; // Prevent collapsing margins
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
...@@ -6,7 +120,7 @@ ...@@ -6,7 +120,7 @@
body.discussion, .discussion-thread.expanded { body.discussion, .discussion-thread.expanded {
.forum-thread-main-wrapper { .forum-thread-main-wrapper {
margin-bottom: $baseline;
box-shadow: 0 1px 3px $shadow; box-shadow: 0 1px 3px $shadow;
} }
} }
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