diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee index 57f63a6500..2863658b9c 100644 --- a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee +++ b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee @@ -4,3 +4,605 @@ class @DiscussionSpecHelper DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []}) window.$$course_id = "edX/999/test" window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []}) + DiscussionUtil.setUser(window.user) + + @makeModerator = () -> + DiscussionUtil.roleIds["Moderator"].push(parseInt(window.user.id)) + + @setUnderscoreFixtures = -> + appendSetFixtures(""" +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""") diff --git a/common/static/coffee/spec/discussion/utils_spec.coffee b/common/static/coffee/spec/discussion/utils_spec.coffee new file mode 100644 index 0000000000..e4285014e5 --- /dev/null +++ b/common/static/coffee/spec/discussion/utils_spec.coffee @@ -0,0 +1,27 @@ +describe 'DiscussionUtil', -> + beforeEach -> + DiscussionSpecHelper.setUpGlobals() + + describe "updateWithUndo", -> + + it "calls through to safeAjax with correct params, and reverts the model in case of failure", -> + deferred = $.Deferred() + spyOn($, "ajax").andReturn(deferred) + spyOn(DiscussionUtil, "safeAjax").andCallThrough() + + model = new Backbone.Model({hello: false, number: 42}) + updates = {hello: "world"} + + # the ajax request should fire and the model should be updated + res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar"}, "error message") + expect(DiscussionUtil.safeAjax).toHaveBeenCalled() + expect(model.attributes).toEqual({hello: "world", number: 42}) + + # the error message callback should be set up correctly + spyOn(DiscussionUtil, "discussionAlert") + DiscussionUtil.safeAjax.mostRecentCall.args[0].error() + expect(DiscussionUtil.discussionAlert).toHaveBeenCalledWith("Sorry", "error message") + + # if the ajax call ends in failure, the model state should be reverted + deferred.reject() + expect(model.attributes).toEqual({hello: false, number: 42}) diff --git a/common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee index 06e8db3dc7..c45021260f 100644 --- a/common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_content_view_spec.coffee @@ -1,26 +1,7 @@ describe "DiscussionContentView", -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures( - """ -
-
- - 0 votes (click to vote) -

Post Title

-

- robot - less than a minute ago -

-
-

Post body.

-
- Report Misuse
-
- Pin Thread
-
- """ - ) + DiscussionSpecHelper.setUnderscoreFixtures() @threadData = { id: '01234567', @@ -35,7 +16,8 @@ describe "DiscussionContentView", -> } @thread = new Thread(@threadData) @view = new DiscussionContentView({ model: @thread }) - @view.setElement($('.discussion-post')) + @view.setElement($('#fixture-element')) + @view.render() it 'defines the tag', -> expect($('#jasmine-fixtures')).toExist @@ -59,15 +41,3 @@ describe "DiscussionContentView", -> @thread.set("abuse_flaggers",temp_array) @thread.unflagAbuse() expect(@thread.get 'abuse_flaggers').toEqual [] - - it 'renders the vote button properly', -> - DiscussionViewSpecHelper.checkRenderVote(@view, @thread) - - it 'votes correctly', -> - DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, false) - - it 'unvotes correctly', -> - DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, false) - - it 'toggles the vote correctly', -> - DiscussionViewSpecHelper.checkToggleVote(@view, @thread) diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee index 0d171dbda9..e2d4d0b493 100644 --- a/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee @@ -1,89 +1,144 @@ describe "DiscussionThreadShowView", -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures( - """ -
- - 0 votes (click to vote) - -
- - Pin Thread -
-
- """ - ) + DiscussionSpecHelper.setUnderscoreFixtures() + @user = DiscussionUtil.getUser() @threadData = { id: "dummy", - user_id: user.id, + user_id: @user.id, + username: @user.get('username'), course_id: $$course_id, + title: "dummy title", body: "this is a thread", created_at: "2013-04-03T20:08:39Z", abuse_flaggers: [], - votes: {up_count: "42"} + votes: {up_count: 42}, + thread_type: "discussion", + closed: false, + pinned: false, + type: "thread" # TODO - silly that this needs to be explicitly set } @thread = new Thread(@threadData) @view = new DiscussionThreadShowView({ model: @thread }) - @view.setElement($(".discussion-post")) + @view.setElement($("#fixture-element")) + @spyOn(@view, "convertMath") - it "renders the vote correctly", -> - DiscussionViewSpecHelper.checkRenderVote(@view, @thread) + describe "voting", -> - it "votes correctly", -> - DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true) + it "renders the vote state correctly", -> + DiscussionViewSpecHelper.checkRenderVote(@view, @thread) - it "unvotes correctly", -> - DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true) + it "votes correctly via click", -> + DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click")) - it 'toggles the vote correctly', -> - DiscussionViewSpecHelper.checkToggleVote(@view, @thread) + it "votes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32})) - it "vote button activates on appropriate events", -> - DiscussionViewSpecHelper.checkVoteButtonEvents(@view) + it "unvotes correctly via click", -> + DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click")) - describe "renderPinned", -> - describe "for an unpinned thread", -> - it "renders correctly when pinning is allowed", -> - @thread.updateInfo({ability: {can_openclose: true}}) - @view.renderPinned() - pinElem = @view.$(".discussion-pin") - expect(pinElem.length).toEqual(1) - expect(pinElem).not.toHaveClass("pinned") - expect(pinElem).toHaveClass("notpinned") - expect(pinElem.find(".pin-label")).toHaveHtml("Pin Thread") - expect(pinElem).not.toHaveAttr("data-tooltip") - expect(pinElem).toHaveAttr("aria-pressed", "false") + it "unvotes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32})) - # If pinning is not allowed, the pinning UI is not present, so no - # test is needed + describe "pinning", -> - describe "for a pinned thread", -> - beforeEach -> - @thread.set("pinned", true) + expectPinnedRendered = (view, model) -> + pinned = model.get('pinned') + button = view.$el.find(".action-pin") + expect(button.hasClass("is-checked")).toBe(pinned) + expect(button.attr("aria-checked")).toEqual(pinned.toString()) - it "renders correctly when unpinning is allowed", -> - @thread.updateInfo({ability: {can_openclose: true}}) - @view.renderPinned() - pinElem = @view.$(".discussion-pin") - expect(pinElem.length).toEqual(1) - expect(pinElem).toHaveClass("pinned") - expect(pinElem).not.toHaveClass("notpinned") - expect(pinElem.find(".pin-label")).toHaveHtml("Pinned, click to unpin") - expect(pinElem).toHaveAttr("data-tooltip", "Click to unpin") - expect(pinElem).toHaveAttr("aria-pressed", "true") + it "renders the pinned state correctly", -> + @view.render() + expectPinnedRendered(@view, @thread) + @thread.set('pinned', false) + @view.render() + expectPinnedRendered(@view, @thread) + @thread.set('pinned', true) + @view.render() + expectPinnedRendered(@view, @thread) - it "renders correctly when unpinning is not allowed", -> - @view.renderPinned() - pinElem = @view.$(".discussion-pin") - expect(pinElem.length).toEqual(1) - expect(pinElem).toHaveClass("pinned") - expect(pinElem).not.toHaveClass("notpinned") - expect(pinElem.find(".pin-label")).toHaveHtml("Pinned") - expect(pinElem).not.toHaveAttr("data-tooltip") - expect(pinElem).not.toHaveAttr("aria-pressed") - + it "exposes the pinning control only to authorized users", -> + @thread.updateInfo({ability: {can_openclose: false}}) + @view.render() + expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist() + @thread.updateInfo({ability: {can_openclose: true}}) + @view.render() + expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist() - it "pinning button activates on appropriate events", -> - DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".admin-pin") + it "handles events correctly", -> + @view.render() + DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin") + + describe "labels", -> + + expectOneElement = (view, selector, visible=true) => + view.render() + elements = view.$el.find(selector) + expect(elements.length).toEqual(1) + if visible + expect(elements).not.toHaveClass("is-hidden") + else + expect(elements).toHaveClass("is-hidden") + + it 'displays the closed label when appropriate', -> + expectOneElement(@view, '.post-label-closed', false) + @thread.set('closed', true) + expectOneElement(@view, '.post-label-closed') + + it 'displays the pinned label when appropriate', -> + expectOneElement(@view, '.post-label-pinned', false) + @thread.set('pinned', true) + expectOneElement(@view, '.post-label-pinned') + + it 'displays the reported label when appropriate for a non-staff user', -> + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should not be labelled + @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported', false) + + it 'displays the reported label when appropriate for a flag moderator', -> + DiscussionSpecHelper.makeModerator() + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should still be labelled + @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported') + + describe "author display", -> + + beforeEach -> + @thread.set('user_url', 'test_user_url') + + checkUserLink = (element, is_ta, is_staff) -> + expect(element.find('a.username').length).toEqual(1) + expect(element.find('a.username').text()).toEqual('test_user') + expect(element.find('a.username').attr('href')).toEqual('test_user_url') + expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0) + expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0) + + it "renders correctly for a student-authored thread", -> + $el = $('#fixture-element').html(@view.getAuthorDisplay()) + checkUserLink($el, false, false) + + it "renders correctly for a community TA-authored thread", -> + @thread.set('community_ta_authored', true) + $el = $('#fixture-element').html(@view.getAuthorDisplay()) + checkUserLink($el, true, false) + + it "renders correctly for a staff-authored thread", -> + @thread.set('staff_authored', true) + $el = $('#fixture-element').html(@view.getAuthorDisplay()) + checkUserLink($el, false, true) + + it "renders correctly for an anonymously-authored thread", -> + @thread.set('username', null) + $el = $('#fixture-element').html(@view.getAuthorDisplay()) + expect($el.find('a.username').length).toEqual(0) + expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/) diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee index 016fdcd863..9c42456d86 100644 --- a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee @@ -1,38 +1,7 @@ describe "DiscussionThreadView", -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures( - """ - - - -
- """ - ) + DiscussionSpecHelper.setUnderscoreFixtures() jasmine.Clock.useMock() @threadData = DiscussionViewSpecHelper.makeThreadWithProps({}) @@ -73,7 +42,7 @@ describe "DiscussionThreadView", -> describe "tab mode", -> 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", -> it "correctly render for a thread with no responses", -> @@ -114,7 +83,7 @@ describe "DiscussionThreadView", -> describe "inline mode", -> beforeEach -> - @view = new DiscussionThreadView({ model: @thread, el: $(".thread-fixture"), mode: "inline"}) + @view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "inline"}) describe "render", -> it "shows content that should be visible when collapsed", -> @@ -159,7 +128,7 @@ describe "DiscussionThreadView", -> beforeEach -> @thread.set("thread_type", "question") @view = new DiscussionThreadView( - {model: @thread, el: $(".thread-fixture"), mode: "tab"} + {model: @thread, el: $("#fixture-element"), mode: "tab"} ) renderTestCase = (view, numEndorsed, numNonEndorsed) -> @@ -173,8 +142,8 @@ describe "DiscussionThreadView", -> non_endorsed_resp_total: numNonEndorsed } ) - expect(view.$(".js-marked-answer-list .response").length).toEqual(numEndorsed) - expect(view.$(".js-response-list .response").length).toEqual(numNonEndorsed) + expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed) + expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed) assertResponseCountAndPaginationCorrect( view, ngettext( @@ -209,8 +178,8 @@ describe "DiscussionThreadView", -> non_endorsed_resp_total: 41 }) @view.$el.find(".load-response-button").click() - expect($(".js-marked-answer-list .response").length).toEqual(3) - expect($(".js-response-list .response").length).toEqual(6) + expect($(".js-marked-answer-list .discussion-response").length).toEqual(3) + expect($(".js-response-list .discussion-response").length).toEqual(6) assertResponseCountAndPaginationCorrect( @view, "41 other responses", diff --git a/common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee b/common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee index cc37048171..be0c1bca96 100644 --- a/common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_view_spec_helper.coffee @@ -10,107 +10,54 @@ class @DiscussionViewSpecHelper unread_comments_count: 0, comments_count: 0, abuse_flaggers: [], - body: "" + body: "", + title: "dummy title", + created_at: "2014-08-18T01:02:03Z" } $.extend(thread, props) - @expectVoteRendered = (view, voted) -> - button = view.$el.find(".vote-btn") - if voted - expect(button.hasClass("is-cast")).toBe(true) - expect(button.attr("aria-pressed")).toEqual("true") - expect(button.attr("data-tooltip")).toEqual("remove vote") - expect(button.text()).toEqual("43 votes (click to remove your vote)") - else - expect(button.hasClass("is-cast")).toBe(false) - expect(button.attr("aria-pressed")).toEqual("false") - expect(button.attr("data-tooltip")).toEqual("vote") - expect(button.text()).toEqual("42 votes (click to vote)") + @expectVoteRendered = (view, model, user) -> + button = view.$el.find(".action-vote") + expect(button.hasClass("is-checked")).toBe(user.voted(model)) + expect(button.attr("aria-checked")).toEqual(user.voted(model).toString()) + expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$") + expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$") @checkRenderVote = (view, model) -> - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, false) + view.render() + DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user) window.user.vote(model) - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, true) + view.render() + DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user) window.user.unvote(model) - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, false) - - @checkVote = (view, model, modelData, checkRendering) -> - view.renderVote() - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, false) + view.render() + DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user) + triggerVoteEvent = (view, event, expectedUrl) -> + deferred = $.Deferred() spyOn($, "ajax").andCallFake((params) => - newModelData = {} - $.extend(newModelData, modelData, {votes: {up_count: "43"}}) - params.success(newModelData, "success") - # Caller invokes always function on return value but it doesn't matter here - {always: ->} + expect(params.url.toString()).toEqual(expectedUrl) + return deferred ) - - view.vote() - expect(window.user.voted(model)).toBe(true) - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, true) + view.render() + view.$el.find(".action-vote").trigger(event) expect($.ajax).toHaveBeenCalled() - $.ajax.reset() + deferred.resolve() - # Check idempotence - view.vote() - expect(window.user.voted(model)).toBe(true) - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, true) - expect($.ajax).toHaveBeenCalled() + @checkUpvote = (view, model, user, event) -> + expect(model.id in user.get('upvoted_ids')).toBe(false) + initialVoteCount = model.get('votes').up_count + triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1") + expect(model.id in user.get('upvoted_ids')).toBe(true) + expect(model.get('votes').up_count).toEqual(initialVoteCount + 1) - @checkUnvote = (view, model, modelData, checkRendering) -> - window.user.vote(model) - expect(window.user.voted(model)).toBe(true) - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, true) - - spyOn($, "ajax").andCallFake((params) => - newModelData = {} - $.extend(newModelData, modelData, {votes: {up_count: "42"}}) - params.success(newModelData, "success") - # Caller invokes always function on return value but it doesn't matter here - {always: ->} - ) - - view.unvote() - expect(window.user.voted(model)).toBe(false) - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, false) - expect($.ajax).toHaveBeenCalled() - $.ajax.reset() - - # Check idempotence - view.unvote() - expect(window.user.voted(model)).toBe(false) - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, false) - expect($.ajax).toHaveBeenCalled() - - @checkToggleVote = (view, model) -> - event = {preventDefault: ->} - spyOn(event, "preventDefault") - spyOn(view, "vote").andCallFake(() -> window.user.vote(model)) - spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model)) - - expect(window.user.voted(model)).toBe(false) - view.toggleVote(event) - expect(view.vote).toHaveBeenCalled() - expect(view.unvote).not.toHaveBeenCalled() - expect(event.preventDefault.callCount).toEqual(1) - - view.vote.reset() - view.unvote.reset() - expect(window.user.voted(model)).toBe(true) - view.toggleVote(event) - expect(view.vote).not.toHaveBeenCalled() - expect(view.unvote).toHaveBeenCalled() - expect(event.preventDefault.callCount).toEqual(2) + @checkUnvote = (view, model, user, event) -> + user.vote(model) + expect(model.id in user.get('upvoted_ids')).toBe(true) + initialVoteCount = model.get('votes').up_count + triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1") + expect(user.get('upvoted_ids')).toEqual([]) + expect(model.get('votes').up_count).toEqual(initialVoteCount - 1) @checkButtonEvents = (view, viewFunc, buttonSelector) -> spy = spyOn(view, viewFunc) @@ -126,7 +73,7 @@ class @DiscussionViewSpecHelper expect(spy).toHaveBeenCalled() @checkVoteButtonEvents = (view) -> - @checkButtonEvents(view, "toggleVote", ".vote-btn") + @checkButtonEvents(view, "toggleVote", ".action-vote") @setNextResponseContent = (content) -> $.ajax.andCallFake( diff --git a/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee index e916954b09..a79a4e29cf 100644 --- a/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee @@ -2,25 +2,7 @@ describe 'ResponseCommentShowView', -> beforeEach -> DiscussionSpecHelper.setUpGlobals() # set up the container for the response to go in - setFixtures """ -
    - - """ + DiscussionSpecHelper.setUnderscoreFixtures() # set up a model for a new Comment @comment = new Comment { @@ -47,11 +29,6 @@ describe 'ResponseCommentShowView', -> beforeEach -> spyOn(@view, 'renderAttrs') - spyOn(@view, 'markAsStaff') - - it 'produces the correct HTML', -> - @view.render() - expect(@view.el.innerHTML).toContain('"discussion-flag-abuse notflagged"') it 'can be flagged for abuse', -> @comment.flagAbuse() @@ -91,3 +68,35 @@ describe 'ResponseCommentShowView', -> @view.bind "comment:edit", triggerTarget @view.edit() expect(triggerTarget).toHaveBeenCalled() + + describe "labels", -> + + expectOneElement = (view, selector, visible=true) => + view.render() + elements = view.$el.find(selector) + expect(elements.length).toEqual(1) + if visible + expect(elements).not.toHaveClass("is-hidden") + else + expect(elements).toHaveClass("is-hidden") + + it 'displays the reported label when appropriate for a non-staff user', -> + @comment.set('abuse_flaggers', []) + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should not be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported', false) + + it 'displays the reported label when appropriate for a flag moderator', -> + DiscussionSpecHelper.makeModerator() + @comment.set('abuse_flaggers', []) + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should still be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported') diff --git a/common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee b/common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee index 979343193c..7c1b744d8e 100644 --- a/common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/response_comment_view_spec.coffee @@ -10,19 +10,9 @@ describe 'ResponseCommentView', -> abuse_flaggers: ['123'] roles: ['Student'] } - setFixtures """ - - -
    - """ - @view = new ResponseCommentView({ model: @comment, el: $("#response-comment-fixture") }) + DiscussionSpecHelper.setUnderscoreFixtures() + + @view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") }) spyOn(ResponseCommentShowView.prototype, "convertMath") spyOn(DiscussionUtil, "makeWmdEditor") @view.render() @@ -95,8 +85,7 @@ describe 'ResponseCommentView', -> expect(@view._delete).toHaveBeenCalled() @view.showView.trigger "comment:edit", makeEventSpy() expect(@view.edit).toHaveBeenCalled() - expect(@view.$("#response-comment-show-div").length).toEqual(1) - expect(@view.$("#response-comment-edit-div").length).toEqual(0) + expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form") describe 'renderEditView', -> it 'renders the edit view, removes the show view, and registers event handlers', -> @@ -107,8 +96,7 @@ describe 'ResponseCommentView', -> expect(@view.update).toHaveBeenCalled() @view.editView.trigger "comment:cancel_edit", makeEventSpy() expect(@view.cancelEdit).toHaveBeenCalled() - expect(@view.$("#response-comment-show-div").length).toEqual(0) - expect(@view.$("#response-comment-edit-div").length).toEqual(1) + expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form") describe 'edit', -> it 'triggers the appropriate event and switches to the edit view', -> @@ -135,6 +123,8 @@ describe 'ResponseCommentView', -> describe 'update', -> beforeEach -> @updatedBody = "updated body" + # Markdown code creates the editor, so we simulate that here + @view.$el.find(".edit-comment-body").html($("")) @view.$el.find(".edit-comment-body textarea").val(@updatedBody) spyOn(@view, 'cancelEdit') spyOn($, "ajax").andCallFake( diff --git a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee index 786713c607..e545b2676e 100644 --- a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee @@ -1,39 +1,9 @@ describe "ThreadResponseShowView", -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures( - """ - - -
    - """ - ) + DiscussionSpecHelper.setUnderscoreFixtures() + @user = DiscussionUtil.getUser() @thread = new Thread({"thread_type": "discussion"}) @commentData = { id: "dummy", @@ -43,32 +13,34 @@ describe "ThreadResponseShowView", -> created_at: "2013-04-03T20:08:39Z", endorsed: false, abuse_flaggers: [], - votes: {up_count: "42"} + votes: {up_count: 42}, + type: "comment" } @comment = new Comment(@commentData) @comment.set("thread", @thread) - @view = new ThreadResponseShowView({ model: @comment }) - @view.setElement($(".discussion-post")) + @view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") }) # Avoid unnecessary boilerplate spyOn(ThreadResponseShowView.prototype, "convertMath") @view.render() - it "renders the vote correctly", -> - DiscussionViewSpecHelper.checkRenderVote(@view, @comment) + describe "voting", -> - it "votes correctly", -> - DiscussionViewSpecHelper.checkVote(@view, @comment, @commentData, true) + it "renders the vote state correctly", -> + DiscussionViewSpecHelper.checkRenderVote(@view, @comment) - it "unvotes correctly", -> - DiscussionViewSpecHelper.checkUnvote(@view, @comment, @commentData, true) + it "votes correctly via click", -> + DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click")) - it 'toggles the vote correctly', -> - DiscussionViewSpecHelper.checkToggleVote(@view, @comment) + it "votes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32})) - it "vote button activates on appropriate events", -> - DiscussionViewSpecHelper.checkVoteButtonEvents(@view) + it "unvotes correctly via click", -> + DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click")) + + it "unvotes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32})) it "renders endorsement correctly for a marked answer in a question thread", -> endorsement = { @@ -81,7 +53,7 @@ describe "ThreadResponseShowView", -> "endorsement": endorsement }) @view.render() - expect(@view.$(".posted-details").text()).toMatch( + expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch( "marked as answer less than a minute ago by " + endorsement.username ) @@ -97,17 +69,45 @@ describe "ThreadResponseShowView", -> }) @view.render() expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago") - expect(@view.$(".posted-details").text()).not.toMatch(" by ") + expect(@view.$(".posted-details").text()).not.toMatch("\sby\s") + + it "renders endorsement correctly for an endorsed response in a discussion thread", -> + endorsement = { + "username": "test_endorser", + "time": new Date().toISOString() + } + @thread.set("thread_type", "discussion") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch( + "endorsed less than a minute ago by " + endorsement.username + ) + + it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", -> + endorsement = { + "username": null, + "time": new Date().toISOString() + } + @thread.set("thread_type", "discussion") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago") + expect(@view.$(".posted-details").text()).not.toMatch("\sby\s") it "re-renders correctly when endorsement changes", -> DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]}) @thread.set("thread_type", "question") + @view.render() expect(@view.$(".posted-details").text()).not.toMatch("marked as answer") - @view.$(".action-endorse").click() - expect(@view.$(".posted-details").text()).toMatch( - "marked as answer less than a minute ago by " + user.get("username") - ) - @view.$(".action-endorse").click() + @view.$(".action-answer").click() + expect(@view.$(".posted-details").text()).toMatch("marked as answer") + @view.$(".action-answer").click() expect(@view.$(".posted-details").text()).not.toMatch("marked as answer") it "allows a moderator to mark an answer in a question thread", -> @@ -117,12 +117,11 @@ describe "ThreadResponseShowView", -> "user_id": (parseInt(window.user.id) + 1).toString() }) @view.render() - endorseButton = @view.$(".action-endorse") + endorseButton = @view.$(".action-answer") expect(endorseButton.length).toEqual(1) - expect(endorseButton).not.toHaveCss({"display": "none"}) - expect(endorseButton).toHaveClass("is-clickable") + expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden") endorseButton.click() - expect(endorseButton).toHaveClass("is-endorsed") + expect(endorseButton).toHaveClass("is-checked") it "allows the author of a question thread to mark an answer", -> @thread.set({ @@ -130,12 +129,11 @@ describe "ThreadResponseShowView", -> "user_id": window.user.id }) @view.render() - endorseButton = @view.$(".action-endorse") + endorseButton = @view.$(".action-answer") expect(endorseButton.length).toEqual(1) - expect(endorseButton).not.toHaveCss({"display": "none"}) - expect(endorseButton).toHaveClass("is-clickable") + expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden") endorseButton.click() - expect(endorseButton).toHaveClass("is-endorsed") + expect(endorseButton).toHaveClass("is-checked") it "does not allow the author of a discussion thread to endorse", -> @thread.set({ @@ -145,10 +143,7 @@ describe "ThreadResponseShowView", -> @view.render() endorseButton = @view.$(".action-endorse") expect(endorseButton.length).toEqual(1) - expect(endorseButton).toHaveCss({"display": "none"}) - expect(endorseButton).not.toHaveClass("is-clickable") - endorseButton.click() - expect(endorseButton).not.toHaveClass("is-endorsed") + expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden") it "does not allow a student who is not the author of a question thread to mark an answer", -> @thread.set({ @@ -156,9 +151,70 @@ describe "ThreadResponseShowView", -> "user_id": (parseInt(window.user.id) + 1).toString() }) @view.render() - endorseButton = @view.$(".action-endorse") + endorseButton = @view.$(".action-answer") expect(endorseButton.length).toEqual(1) - expect(endorseButton).toHaveCss({"display": "none"}) - expect(endorseButton).not.toHaveClass("is-clickable") - endorseButton.click() - expect(endorseButton).not.toHaveClass("is-endorsed") + expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden") + + describe "labels", -> + + expectOneElement = (view, selector, visible=true) => + view.render() + elements = view.$el.find(selector) + expect(elements.length).toEqual(1) + if visible + expect(elements).not.toHaveClass("is-hidden") + else + expect(elements).toHaveClass("is-hidden") + + it 'displays the reported label when appropriate for a non-staff user', -> + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should not be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported', false) + + it 'displays the reported label when appropriate for a flag moderator', -> + DiscussionSpecHelper.makeModerator() + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should still be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported') + + describe "endorser display", -> + + beforeEach -> + @comment.set('endorsement', { + "username": "test_endorser", + "time": new Date().toISOString() + }) + spyOn(DiscussionUtil, 'urlFor').andReturn('test_endorser_url') + + checkUserLink = (element, is_ta, is_staff) -> + expect(element.find('a.username').length).toEqual(1) + expect(element.find('a.username').text()).toEqual('test_endorser') + expect(element.find('a.username').attr('href')).toEqual('test_endorser_url') + expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0) + expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0) + + it "renders nothing when the response has not been endorsed", -> + @comment.set('endorsement', null) + expect(@view.getEndorserDisplay()).toBeNull() + + it "renders correctly for a student-endorsed response", -> + $el = $('#fixture-element').html(@view.getEndorserDisplay()) + checkUserLink($el, false, false) + + it "renders correctly for a community TA-endorsed response", -> + spyOn(DiscussionUtil, 'isTA').andReturn(true) + $el = $('#fixture-element').html(@view.getEndorserDisplay()) + checkUserLink($el, true, false) + + it "renders correctly for a staff-endorsed response", -> + spyOn(DiscussionUtil, 'isStaff').andReturn(true) + $el = $('#fixture-element').html(@view.getEndorserDisplay()) + checkUserLink($el, false, true) diff --git a/common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee b/common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee index 48a1160cbd..24776cc0ca 100644 --- a/common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/thread_response_view_spec.coffee @@ -1,17 +1,12 @@ describe 'ThreadResponseView', -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures """ - -
    - """ + DiscussionSpecHelper.setUnderscoreFixtures() + @response = new Comment { children: [{}, {}] } - @view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")}) + @view = new ThreadResponseView({model: @response, el: $("#fixture-element")}) spyOn(ThreadResponseShowView.prototype, "render") spyOn(ResponseCommentView.prototype, "render") @@ -24,7 +19,7 @@ describe 'ThreadResponseView', -> it 'hides "show comments" link if collapseComments is set but response has no comments', -> @response = new Comment { children: [] } @view = new ThreadResponseView({ - model: @response, el: $("#thread-response-fixture"), + model: @response, el: $("#fixture-element"), collapseComments: true }) @view.render() @@ -33,7 +28,7 @@ describe 'ThreadResponseView', -> it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', -> @view = new ThreadResponseView({ - model: @response, el: $("#thread-response-fixture"), + model: @response, el: $("#fixture-element"), collapseComments: true }) @view.render() diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/coffee/src/discussion/content.coffee index 56867ba877..ecf2f2f733 100644 --- a/common/static/coffee/src/discussion/content.coffee +++ b/common/static/coffee/src/discussion/content.coffee @@ -108,13 +108,21 @@ if Backbone? @get("abuse_flaggers").pop(window.user.get('id')) @trigger "change", @ + isFlagged: -> + user = DiscussionUtil.getUser() + flaggers = @get("abuse_flaggers") + user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0)) + + incrementVote: (increment) -> + newVotes = _.clone(@get("votes")) + newVotes.up_count = newVotes.up_count + increment + @set("votes", newVotes) + vote: -> - @get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1 - @trigger "change", @ + @incrementVote(1) unvote: -> - @get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1 - @trigger "change", @ + @incrementVote(-1) class @Thread extends @Content urlMappers: diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index b4ef80a7b1..3beda4e82b 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -21,15 +21,14 @@ class @DiscussionUtil @setUser: (user) -> @user = user + @getUser: () -> + @user + @loadRoles: (roles)-> @roleIds = roles - @loadFlagModerator: (what)-> - @isFlagModerator = ((what=="True") or (what == 1)) - @loadRolesFromContainer: -> @loadRoles($("#discussion-container").data("roles")) - @loadFlagModerator($("#discussion-container").data("flag-moderator")) @isStaff: (user_id) -> user_id ?= @user?.id @@ -162,6 +161,13 @@ class @DiscussionUtil params["$loading"].loaded() return request + @updateWithUndo: (model, updates, safeAjaxParams, errorMsg) -> + if errorMsg + safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg) + undo = _.pick(model.attributes, _.keys(updates)) + model.set(updates) + @safeAjax(safeAjaxParams).fail(() -> model.set(undo)) + @bindLocalEvents: ($local, eventsHandler) -> for eventSelector, handler of eventsHandler [event, selector] = eventSelector.split(' ') diff --git a/common/static/coffee/src/discussion/views/discussion_content_view.coffee b/common/static/coffee/src/discussion/views/discussion_content_view.coffee index ec555d500b..58f30478ba 100644 --- a/common/static/coffee/src/discussion/views/discussion_content_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_content_view.coffee @@ -8,30 +8,6 @@ if Backbone? (event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse) attrRenderer: - closed: (closed) -> - return if not @$(".action-openclose").length - return if not @$(".post-status-closed").length - if closed - @$(".post-status-closed").show() - @$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Close"), gettext("Open"))) - @$(".discussion-reply-new").hide() - else - @$(".post-status-closed").hide() - @$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Open"), gettext("Close"))) - @$(".discussion-reply-new").show() - - voted: (voted) -> - - votes_point: (votes_point) -> - - comments_count: (comments_count) -> - - subscribed: (subscribed) -> - if subscribed - @$(".dogear").addClass("is-followed").attr("aria-checked", "true") - else - @$(".dogear").removeClass("is-followed").attr("aria-checked", "false") - ability: (ability) -> for action, selector of @abilityRenderer if not ability[action] @@ -41,14 +17,22 @@ if Backbone? abilityRenderer: editable: - enable: -> @$(".action-edit").closest("li").show() - disable: -> @$(".action-edit").closest("li").hide() + enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden") + disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden") can_delete: - enable: -> @$(".action-delete").closest("li").show() - disable: -> @$(".action-delete").closest("li").hide() + enable: -> @$(".action-delete").closest(".actions-item").removeClass("is-hidden") + disable: -> @$(".action-delete").closest(".actions-item").addClass("is-hidden") can_openclose: - enable: -> @$(".action-openclose").closest("li").show() - disable: -> @$(".action-openclose").closest("li").hide() + enable: -> + _.each( + [".action-close", ".action-pin"], + (selector) => @$(selector).closest(".actions-item").removeClass("is-hidden") + ) + disable: -> + _.each( + [".action-close", ".action-pin"], + (selector) => @$(selector).closest(".actions-item").addClass("is-hidden") + ) renderPartialAttrs: -> for attr, value of @model.changedAttributes() @@ -76,114 +60,237 @@ if Backbone? initialize: -> @model.bind('change', @renderPartialAttrs, @) - - - toggleFollowing: (event) => - event.preventDefault() - $elem = $(event.target) - url = null - if not @model.get('subscribed') - @model.follow() - url = @model.urlFor("follow") - else - @model.unfollow() - url = @model.urlFor("unfollow") - DiscussionUtil.safeAjax - $elem: $elem - url: url - type: "POST" - - toggleFlagAbuse: (event) => - event.preventDefault() - if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @unFlagAbuse() - else - @flagAbuse() - - flagAbuse: => - url = @model.urlFor("flagAbuse") - DiscussionUtil.safeAjax - $elem: @$(".discussion-flag-abuse") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - ### - note, we have to clone the array in order to trigger a change event - ### - temp_array = _.clone(@model.get('abuse_flaggers')); - temp_array.push(window.user.id) - @model.set('abuse_flaggers', temp_array) - - unFlagAbuse: => - url = @model.urlFor("unFlagAbuse") - DiscussionUtil.safeAjax - $elem: @$(".discussion-flag-abuse") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - temp_array = _.clone(@model.get('abuse_flaggers')); - temp_array.pop(window.user.id) - # if you're an admin, clear this - if DiscussionUtil.isFlagModerator - temp_array = [] - - @model.set('abuse_flaggers', temp_array) - - renderVote: => - button = @$el.find(".vote-btn") - voted = window.user.voted(@model) - voteNum = @model.get("votes")["up_count"] - button.toggleClass("is-cast", voted) - button.attr("aria-pressed", voted) - button.attr("data-tooltip", if voted then gettext("remove vote") else gettext("vote")) - buttonTextFmt = - if voted - ngettext( - "vote (click to remove your vote)", - "votes (click to remove your vote)", - voteNum - ) - else - ngettext( - "vote (click to vote)", - "votes (click to vote)", - voteNum - ) - buttonTextFmt = "%(voteNum)s%(startSrSpan)s " + buttonTextFmt + "%(endSrSpan)s" - buttonText = interpolate( - buttonTextFmt, - {voteNum: voteNum, startSrSpan: "", endSrSpan: ""}, - true + @listenTo(@model, "change:endorsed", => + if @model instanceof Comment + @trigger("comment:endorse") ) - button.html("" + buttonText) + + class @DiscussionContentShowView extends DiscussionContentView + events: + _.reduce( + [ + [".action-follow", "toggleFollow"], + [".action-answer", "toggleEndorse"], + [".action-endorse", "toggleEndorse"], + [".action-vote", "toggleVote"], + [".action-more", "toggleSecondaryActions"], + [".action-pin", "togglePin"], + [".action-edit", "edit"], + [".action-delete", "_delete"], + [".action-report", "toggleReport"], + [".action-close", "toggleClose"], + ], + (obj, event) => + selector = event[0] + funcName = event[1] + obj["click #{selector}"] = (event) -> @[funcName](event) + obj["keydown #{selector}"] = (event) -> DiscussionUtil.activateOnSpace(event, @[funcName]) + obj + , + {} + ) + + updateButtonState: (selector, checked) => + $button = @$(selector) + $button.toggleClass("is-checked", checked) + $button.attr("aria-checked", checked) + + attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, { + subscribed: (subscribed) -> + @updateButtonState(".action-follow", subscribed) + + endorsed: (endorsed) -> + selector = if @model.get("thread").get("thread_type") == "question" then ".action-answer" else ".action-endorse" + @updateButtonState(selector, endorsed) + $button = @$(selector) + $button.closest(".actions-item").toggleClass("is-hidden", not @model.canBeEndorsed()) + $button.toggleClass("is-checked", endorsed) + + votes: (votes) -> + selector = ".action-vote" + @updateButtonState(selector, window.user.voted(@model)) + button = @$el.find(selector) + numVotes = votes.up_count + button.find(".js-sr-vote-count").html( + interpolate( + ngettext("currently %(numVotes)s vote", "currently %(numVotes)s votes", numVotes), + {numVotes: numVotes}, + true + ) + ) + button.find(".js-visual-vote-count").html( + interpolate( + ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes), + {numVotes: numVotes}, + true + ) + ) + + pinned: (pinned) -> + @updateButtonState(".action-pin", pinned) + @$(".post-label-pinned").toggleClass("is-hidden", not pinned) + + abuse_flaggers: (abuse_flaggers) -> + flagged = @model.isFlagged() + @updateButtonState(".action-report", flagged) + @$(".post-label-reported").toggleClass("is-hidden", not flagged) + + closed: (closed) -> + @updateButtonState(".action-close", closed) + @$(".post-label-closed").toggleClass("is-hidden", not closed) + }) + + toggleSecondaryActions: (event) => + event.preventDefault() + event.stopPropagation() + @secondaryActionsExpanded = !@secondaryActionsExpanded + @$(".action-more").toggleClass("is-expanded", @secondaryActionsExpanded) + @$(".actions-dropdown"). + toggleClass("is-expanded", @secondaryActionsExpanded). + attr("aria-expanded", @secondaryActionsExpanded) + if @secondaryActionsExpanded + if event.type == "keydown" + @$(".action-list-item:first").focus() + $("body").on("click", @toggleSecondaryActions) + $("body").on("keydown", @handleSecondaryActionEscape) + @$(".action-list-item").on("blur", @handleSecondaryActionBlur) + else + $("body").off("click", @toggleSecondaryActions) + $("body").off("keydown", @handleSecondaryActionEscape) + @$(".action-list-item").off("blur", @handleSecondaryActionBlur) + + handleSecondaryActionEscape: (event) => + if event.keyCode == 27 # Esc + @toggleSecondaryActions(event) + @$(".action-more").focus() + + handleSecondaryActionBlur: (event) => + setTimeout( + => + if @secondaryActionsExpanded && @$(".actions-dropdown :focus").length == 0 + @toggleSecondaryActions(event) + , + 10 + ) + + toggleFollow: (event) => + event.preventDefault() + is_subscribing = not @model.get("subscribed") + url = @model.urlFor(if is_subscribing then "follow" else "unfollow") + if is_subscribing + msg = gettext("We had some trouble subscribing you to this thread. Please try again.") + else + msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.") + DiscussionUtil.updateWithUndo( + @model, + {"subscribed": is_subscribing}, + {url: url, type: "POST", $elem: $(event.currentTarget)}, + msg + ) + + toggleEndorse: (event) => + event.preventDefault() + is_endorsing = not @model.get("endorsed") + url = @model.urlFor("endorse") + updates = + endorsed: is_endorsing + endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), time: new Date().toISOString()} else null + if @model.get('thread').get('thread_type') == 'question' + if is_endorsing + msg = gettext("We had some trouble marking this response as an answer. Please try again.") + else + msg = gettext("We had some trouble removing this response as an answer. Please try again.") + else + if is_endorsing + msg = gettext("We had some trouble marking this response endorsed. Please try again.") + else + msg = gettext("We had some trouble removing this endorsement. Please try again.") + beforeFunc = () => @trigger("comment:endorse") + DiscussionUtil.updateWithUndo( + @model, + updates, + {url: url, type: "POST", data: {endorsed: is_endorsing}, beforeSend: beforeFunc, $elem: $(event.currentTarget)}, + msg + ).always(@trigger("comment:endorse")) # ensures UI components get updated to the correct state when ajax completes toggleVote: (event) => event.preventDefault() - if window.user.voted(@model) - @unvote() + user = DiscussionUtil.getUser() + is_voting = not user.voted(@model) + url = @model.urlFor(if is_voting then "upvote" else "unvote") + updates = + upvoted_ids: (if is_voting then _.union else _.difference)(user.get('upvoted_ids'), [@model.id]) + DiscussionUtil.updateWithUndo( + user, + updates, + {url: url, type: "POST", $elem: $(event.currentTarget)}, + gettext("We had some trouble saving your vote. Please try again.") + ).done(() => if is_voting then @model.vote() else @model.unvote()) + + togglePin: (event) => + event.preventDefault() + is_pinning = not @model.get("pinned") + url = @model.urlFor(if is_pinning then "pinThread" else "unPinThread") + if is_pinning + msg = gettext("We had some trouble pinning this thread. Please try again.") else - @vote() + msg = gettext("We had some trouble unpinning this thread. Please try again.") + DiscussionUtil.updateWithUndo( + @model, + {pinned: is_pinning}, + {url: url, type: "POST", $elem: $(event.currentTarget)}, + msg + ) - vote: => - window.user.vote(@model) - url = @model.urlFor("upvote") - DiscussionUtil.safeAjax - $elem: @$el.find(".vote-btn") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set(response) + toggleReport: (event) => + event.preventDefault() + if @model.isFlagged() + is_flagging = false + msg = gettext("We had some trouble removing your flag on this post. Please try again.") + else + is_flagging = true + msg = gettext("We had some trouble reporting this post. Please try again.") + url = @model.urlFor(if is_flagging then "flagAbuse" else "unFlagAbuse") + updates = + abuse_flaggers: (if is_flagging then _.union else _.difference)(@model.get("abuse_flaggers"), [DiscussionUtil.getUser().id]) + DiscussionUtil.updateWithUndo( + @model, + updates, + {url: url, type: "POST", $elem: $(event.currentTarget)}, + msg + ) - unvote: => - window.user.unvote(@model) - url = @model.urlFor("unvote") - DiscussionUtil.safeAjax - $elem: @$el.find(".vote-btn") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set(response) + toggleClose: (event) => + event.preventDefault() + is_closing = not @model.get('closed') + if is_closing + msg = gettext("We had some trouble closing this thread. Please try again.") + else + msg = gettext("We had some trouble reopening this thread. Please try again.") + updates = {closed: is_closing} + DiscussionUtil.updateWithUndo( + @model, + updates, + {url: @model.urlFor("close"), type: "POST", data: updates, $elem: $(event.currentTarget)}, + msg + ) + + getAuthorDisplay: -> + _.template($("#post-user-display-template").html())( + username: @model.get('username') || null + user_url: @model.get('user_url') + is_community_ta: @model.get('community_ta_authored') + is_staff: @model.get('staff_authored') + ) + + getEndorserDisplay: -> + endorsement = @model.get('endorsement') + if endorsement and endorsement.username + _.template($("#post-user-display-template").html())( + username: endorsement.username + user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id) + is_community_ta: DiscussionUtil.isTA(endorsement.user_id) + is_staff: DiscussionUtil.isStaff(endorsement.user_id) + ) + else + null diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee index 7dfb99ec8b..6319b1d950 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee @@ -1,47 +1,27 @@ if Backbone? - class @DiscussionThreadShowView extends DiscussionContentView - - events: - "click .vote-btn": - (event) -> @toggleVote(event) - "keydown .vote-btn": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleVote) - "click .discussion-flag-abuse": "toggleFlagAbuse" - "keydown .discussion-flag-abuse": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse) - "click .admin-pin": - (event) -> @togglePin(event) - "keydown .admin-pin": - (event) -> DiscussionUtil.activateOnSpace(event, @togglePin) - "click .action-follow": "toggleFollowing" - "keydown .action-follow": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleFollowing) - "click .action-edit": "edit" - "click .action-delete": "_delete" - "click .action-openclose": "toggleClosed" - - $: (selector) -> - @$el.find(selector) - + class @DiscussionThreadShowView extends DiscussionContentShowView initialize: (options) -> super() @mode = options.mode or "inline" # allowed values are "tab" or "inline" if @mode not in ["tab", "inline"] throw new Error("invalid mode: " + @mode) - @model.on "change", @updateModelDetails renderTemplate: -> @template = _.template($("#thread-show-template").html()) - context = @model.toJSON() - context.mode = @mode + context = $.extend( + { + mode: @mode, + flagged: @model.isFlagged(), + author_display: @getAuthorDisplay(), + cid: @model.cid + }, + @model.attributes, + ) @template(context) render: -> @$el.html(@renderTemplate()) @delegateEvents() - @renderVote() - @renderFlagged() - @renderPinned() @renderAttrs() @$("span.timeago").timeago() @convertMath() @@ -49,60 +29,6 @@ if Backbone? @highlight @$("h1,h3") @ - renderFlagged: => - if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @$("[data-role=thread-flag]").addClass("flagged") - @$("[data-role=thread-flag]").removeClass("notflagged") - @$(".discussion-flag-abuse").attr("aria-pressed", "true") - @$(".discussion-flag-abuse").attr("data-tooltip", gettext("Click to remove report")) - ### - Translators: The text between start_sr_span and end_span is not shown - in most browsers but will be read by screen readers. - ### - @$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "", "end_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": "", "end_span": ""}, - true - ) - ) - pinElem.attr("data-tooltip", gettext("Click to unpin")) - pinElem.attr("aria-pressed", "true") - else - pinLabelElem.html(gettext("Pinned")) - pinElem.removeAttr("data-tooltip") - pinElem.removeAttr("aria-pressed") - else - # If not pinned and not able to pin, pin is not shown - pinElem.removeClass("pinned") - pinElem.addClass("notpinned") - pinLabelElem.html(gettext("Pin Thread")) - pinElem.removeAttr("data-tooltip") - pinElem.attr("aria-pressed", "false") - - updateModelDetails: => - @renderVote() - @renderFlagged() - @renderPinned() - convertMath: -> element = @$(".post-body") element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() @@ -114,64 +40,6 @@ if Backbone? _delete: (event) -> @trigger "thread:_delete", event - togglePin: (event) => - event.preventDefault() - if @model.get('pinned') - @unPin() - else - @pin() - - pin: => - url = @model.urlFor("pinThread") - DiscussionUtil.safeAjax - $elem: @$(".discussion-pin") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set('pinned', true) - error: => - DiscussionUtil.discussionAlert("Sorry", "We had some trouble pinning this thread. Please try again.") - - unPin: => - url = @model.urlFor("unPinThread") - DiscussionUtil.safeAjax - $elem: @$(".discussion-pin") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set('pinned', false) - error: => - DiscussionUtil.discussionAlert("Sorry", "We had some trouble unpinning this thread. Please try again.") - - toggleClosed: (event) -> - $elem = $(event.target) - url = @model.urlFor('close') - closed = @model.get('closed') - data = { closed: not closed } - DiscussionUtil.safeAjax - $elem: $elem - url: url - data: data - type: "POST" - success: (response, textStatus) => - @model.set('closed', not closed) - @model.set('ability', response.ability) - - toggleEndorse: (event) -> - $elem = $(event.target) - url = @model.urlFor('endorse') - endorsed = @model.get('endorsed') - data = { endorsed: not endorsed } - DiscussionUtil.safeAjax - $elem: $elem - url: url - data: data - type: "POST" - success: (response, textStatus) => - @model.set('endorsed', not endorsed) - highlight: (el) -> if el.html() el.html(el.html().replace(/<mark>/g, "").replace(/<\/mark>/g, "")) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee index be0301bc70..5d312051ed 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee @@ -52,6 +52,12 @@ if Backbone? else # mode == "inline" @collapse() + attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, { + closed: (closed) -> + @$(".discussion-reply-new").toggle(not closed) + @renderAddResponseButton() + }) + expand: (event) -> if event event.preventDefault() @@ -200,8 +206,8 @@ if Backbone? @$el.find(listSelector).append(view.el) view.afterInsert() - renderAddResponseButton: -> - if @model.hasResponses() and @model.can('can_reply') + renderAddResponseButton: => + if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed') @$el.find('div.add-response').show() else @$el.find('div.add-response').hide() @@ -215,9 +221,8 @@ if Backbone? addComment: => @model.comment() - endorseThread: (endorsed) => - is_endorsed = @$el.find(".is-endorsed").length > 0 - @model.set 'endorsed', is_endorsed + endorseThread: => + @model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0 submitComment: (event) -> event.preventDefault() diff --git a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee index f385273300..e25a7ac5db 100644 --- a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee +++ b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee @@ -1,39 +1,23 @@ if Backbone? - class @ResponseCommentShowView extends DiscussionContentView - - events: - "click .action-delete": - (event) -> @_delete(event) - "keydown .action-delete": - (event) -> DiscussionUtil.activateOnSpace(event, @_delete) - "click .action-edit": - (event) -> @edit(event) - "keydown .action-edit": - (event) -> DiscussionUtil.activateOnSpace(event, @edit) - + class @ResponseCommentShowView extends DiscussionContentShowView tagName: "li" - initialize: -> - super() - @model.on "change", @updateModelDetails - - abilityRenderer: - can_delete: - enable: -> @$(".action-delete").show() - disable: -> @$(".action-delete").hide() - editable: - enable: -> @$(".action-edit").show() - disable: -> @$(".action-edit").hide() - render: -> @template = _.template($("#response-comment-show-template").html()) - params = @model.toJSON() + @$el.html( + @template( + _.extend( + { + cid: @model.cid, + author_display: @getAuthorDisplay() + }, + @model.attributes + ) + ) + ) - @$el.html(@template(params)) @delegateEvents() @renderAttrs() - @renderFlagged() - @markAsStaff() @$el.find(".timeago").timeago() @convertMath() @addReplyLink() @@ -51,31 +35,8 @@ if Backbone? body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text() MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]] - markAsStaff: -> - if DiscussionUtil.isStaff(@model.get("user_id")) - @$el.find("a.profile-link").after('' + gettext('staff') + '') - else if DiscussionUtil.isTA(@model.get("user_id")) - @$el.find("a.profile-link").after('' + gettext('Community TA') + '') - _delete: (event) => @trigger "comment:_delete", event - renderFlagged: => - if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @$("[data-role=thread-flag]").addClass("flagged") - @$("[data-role=thread-flag]").removeClass("notflagged") - @$(".discussion-flag-abuse").attr("aria-pressed", "true") - @$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report")) - @$(".discussion-flag-abuse .flag-label").html(gettext("Misuse Reported, click to remove report")) - else - @$("[data-role=thread-flag]").removeClass("flagged") - @$("[data-role=thread-flag]").addClass("notflagged") - @$(".discussion-flag-abuse").attr("aria-pressed", "false") - @$(".discussion-flag-abuse").attr("data-tooltip", gettext("Report Misuse")) - @$(".discussion-flag-abuse .flag-label").html(gettext("Report Misuse")) - - updateModelDetails: => - @renderFlagged() - edit: (event) => @trigger "comment:edit", event diff --git a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee index 79cac11ed1..de71ca3eb2 100644 --- a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee @@ -1,45 +1,27 @@ if Backbone? - class @ThreadResponseShowView extends DiscussionContentView - events: - "click .vote-btn": - (event) -> @toggleVote(event) - "keydown .vote-btn": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleVote) - "click .action-endorse": "toggleEndorse" - "click .action-delete": "_delete" - "click .action-edit": "edit" - "click .discussion-flag-abuse": "toggleFlagAbuse" - "keydown .discussion-flag-abuse": - (event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse) - - 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) - + class @ThreadResponseShowView extends DiscussionContentShowView initialize: -> super() @listenTo(@model, "change", @render) renderTemplate: -> @template = _.template($("#thread-response-show-template").html()) - @template(@model.toJSON()) + context = _.extend( + { + cid: @model.cid, + author_display: @getAuthorDisplay(), + endorser_display: @getEndorserDisplay() + }, + @model.attributes + ) + @template(context) render: -> @$el.html(@renderTemplate()) @delegateEvents() - @renderVote() @renderAttrs() - @renderFlagged() @$el.find(".posted-details .timeago").timeago() @convertMath() - @markAsStaff() @ convertMath: -> @@ -47,58 +29,8 @@ if Backbone? element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]] - markAsStaff: -> - if DiscussionUtil.isStaff(@model.get("user_id")) - @$el.addClass("staff") - @$el.prepend('
    ' + gettext('staff') + '
    ') - else if DiscussionUtil.isTA(@model.get("user_id")) - @$el.addClass("community-ta") - @$el.prepend('
    ' + gettext('Community TA') + '
    ') - edit: (event) -> @trigger "response:edit", event _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": "", "end_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")) diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index ad9485077e..dea713b226 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + from bok_choy.page_object import PageObject from bok_choy.promise import EmptyPromise @@ -39,6 +41,25 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): query = self._find_within(selector) return query.present and query.visible + @contextmanager + def _secondary_action_menu_open(self, ancestor_selector): + """ + Given the selector for an ancestor of a secondary menu, return a context + manager that will open and close the menu + """ + self._find_within(ancestor_selector + " .action-more").click() + EmptyPromise( + lambda: self._is_element_visible(ancestor_selector + " .actions-dropdown"), + "Secondary action menu opened" + ).fulfill() + yield + if self._is_element_visible(ancestor_selector + " .actions-dropdown"): + self._find_within(ancestor_selector + " .action-more").click() + EmptyPromise( + lambda: not self._is_element_visible(ancestor_selector + " .actions-dropdown"), + "Secondary action menu closed" + ).fulfill() + def get_response_total_text(self): """Returns the response count text, or None if not present""" return self._get_element_text(".response-count") @@ -89,11 +110,12 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): def start_response_edit(self, response_id): """Click the edit button for the response, loading the editing view""" - self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click() - EmptyPromise( - lambda: self.is_response_editor_visible(response_id), - "Response edit started" - ).fulfill() + 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""" @@ -120,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): def is_comment_deletable(self, comment_id): """Returns true if the delete comment button is present, false otherwise""" - return self._is_element_visible("#comment_{} div.action-delete".format(comment_id)) + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + return self._is_element_visible("#comment_{} .action-delete".format(comment_id)) def delete_comment(self, comment_id): with self.handle_alert(): - self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click() + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + self._find_within("#comment_{} .action-delete".format(comment_id)).first.click() EmptyPromise( lambda: not self.is_comment_visible(comment_id), "Deleted comment was removed" @@ -132,7 +156,8 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): def is_comment_editable(self, comment_id): """Returns true if the edit comment button is present, false otherwise""" - return self._is_element_visible("#comment_{} .action-edit".format(comment_id)) + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + return self._is_element_visible("#comment_{} .action-edit".format(comment_id)) def is_comment_editor_visible(self, comment_id): """Returns true if the comment editor is present, false otherwise""" @@ -144,15 +169,16 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): def start_comment_edit(self, comment_id): """Click the edit button for the comment, loading the editing view""" old_body = self.get_comment_body(comment_id) - self._find_within("#comment_{} .action-edit".format(comment_id)).first.click() - EmptyPromise( - lambda: ( - self.is_comment_editor_visible(comment_id) and - not self.is_comment_visible(comment_id) and - self._get_comment_editor_value(comment_id) == old_body - ), - "Comment edit started" - ).fulfill() + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + self._find_within("#comment_{} .action-edit".format(comment_id)).first.click() + EmptyPromise( + lambda: ( + self.is_comment_editor_visible(comment_id) and + not self.is_comment_visible(comment_id) and + self._get_comment_editor_value(comment_id) == old_body + ), + "Comment edit started" + ).fulfill() def set_comment_editor_value(self, comment_id, new_body): """Replace the contents of the comment editor""" diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index 5981daffb4..e4506954d5 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -51,10 +51,12 @@ @import "discussion/utilities/variables"; @import "discussion/mixins"; @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/labels"; @import "discussion/elements/navigation"; @import "discussion/views/thread"; +@import "discussion/views/new-post"; @import "discussion/views/response"; @import 'discussion/utilities/developer'; @import 'discussion/utilities/shame'; diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 870162e6ed..5f32b9a9e7 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -42,6 +42,9 @@ $very-light-text: #fff; // ==================== +// COLORS - utility +$transparent: rgba(0,0,0,0); // used when color value is needed for UI width/transitions but element is transparent + // COLORS $black: rgb(0,0,0); $black-t0: rgba($black, 0.125); diff --git a/lms/static/sass/discussion/_discussion.scss b/lms/static/sass/discussion/_discussion.scss index 84fd22491f..db9cd944f5 100644 --- a/lms/static/sass/discussion/_discussion.scss +++ b/lms/static/sass/discussion/_discussion.scss @@ -1,6 +1,5 @@ // forums - main app styling // ==================== - body.discussion { .course-tabs .right { @@ -423,7 +422,7 @@ body.discussion { } h1 { - margin-bottom: $baseline/2; + margin-bottom: ($baseline/4); font-size: 28px; font-weight: 700; letter-spacing: 0; @@ -432,18 +431,14 @@ body.discussion { .posted-details { font-size: 12px; - font-style: italic; color: #888; .username { - display: block; - font-size: 16px; font-weight: 700; } .timeago, .top-post-status { color: inherit; - font-style: italic; } } @@ -456,37 +451,6 @@ body.discussion { p + p { margin-top: $baseline; } - - .dogear { - display: block; - position: absolute; - top: -1px; - right: -1px; - width: 52px; - height: 51px; - background: url(../images/follow-dog-ear.png) 0 -52px no-repeat; - @include transition(none); - - &.is-followed { - background-position: 0 0; - } - } - } - - .discussion-post { - 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, @@ -565,7 +529,7 @@ body.discussion { .discussion-response { @include box-sizing(border-box); border-radius: 3px 3px 0 0; - padding: $baseline $baseline 0; + padding: $baseline; background-color: $white; } .posted-by { @@ -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 { background: $gray-l5; border-radius: 3px; @@ -689,89 +565,6 @@ body.discussion { font-size: 14px; } - .comments { - margin: 0; - border-radius: 0 0 3px 3px; - padding: 0; - background: $gray-l6; - box-shadow: 0 1px 3px -1px $shadow inset; - list-style: none; - - > li { - border-top: 1px solid $gray-l4; - padding: ($baseline/2) $baseline; - } - - - blockquote { - background: $gray-l4; - border-radius: 3px; - padding: ($baseline/4) ($baseline/2); - font-size: 14px; - } - - .comment-form { - @include clearfix; - - .comment-form-input { - padding: ($baseline/4) ($baseline/2); - background-color: $white; - font-size: 14px; - } - - .discussion-submit-comment { - @include blue-button; - float: left; - margin-top: 8px; - } - - .wmd-input { - height: 40px; - } - - .discussion-errors { - margin: 0; - } - } - - .response-body { - font-size: 13px; - margin-bottom: ($baseline/2); - - p + p { - margin-top: 12px; - } - } - - .posted-details { - font-size: 11px; - } - - .staff-label { - margin-left: ($baseline/10); - padding: 0 ($baseline/5); - border-radius: 2px; - background: #009FE2; - font-size: 9px; - font-weight: 700; - font-style: normal; - color: white; - text-transform: uppercase; - } - } - - .community-ta-label{ - margin-left: ($baseline/10); - padding: 0 ($baseline/5); - border-radius: 2px; - background: $forum-color-community-ta; - font-size: 9px; - font-weight: 700; - font-style: normal; - color: white; - text-transform: uppercase; - } - .comment-form { padding: ($baseline/2) 0; @@ -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 { display: none; padding: ($baseline*2.5); @@ -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 .discussion-module { @extend .discussion-body; @@ -993,16 +731,6 @@ body.discussion { margin-bottom: $baseline; @include transition(all .25s linear 0s); - .dogear { - display: none; - } - - &.expanded { - .dogear{ - display: block; - } - } - p { margin-bottom: 0; } @@ -1174,10 +902,6 @@ body.discussion { color: $white; } - .moderator-actions { - padding-left: 0 !important; - } - section.pagination { margin-top: 30px; @@ -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 diff --git a/lms/static/sass/discussion/_mixins.scss b/lms/static/sass/discussion/_mixins.scss index 34d6df7090..0e463cb71f 100644 --- a/lms/static/sass/discussion/_mixins.scss +++ b/lms/static/sass/discussion/_mixins.scss @@ -113,4 +113,44 @@ overflow: hidden; white-space: nowrap; 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; +} diff --git a/lms/static/sass/discussion/elements/_actions.scss b/lms/static/sass/discussion/elements/_actions.scss new file mode 100644 index 0000000000..76afecdfe2 --- /dev/null +++ b/lms/static/sass/discussion/elements/_actions.scss @@ -0,0 +1,313 @@ +.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; + } + } + } +} diff --git a/lms/static/sass/discussion/elements/_labels.scss b/lms/static/sass/discussion/elements/_labels.scss new file mode 100644 index 0000000000..12f6a46ec5 --- /dev/null +++ b/lms/static/sass/discussion/elements/_labels.scss @@ -0,0 +1,37 @@ +// 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 diff --git a/lms/static/sass/discussion/elements/_navigation.scss b/lms/static/sass/discussion/elements/_navigation.scss index 24ca8aa083..76b33427e9 100644 --- a/lms/static/sass/discussion/elements/_navigation.scss +++ b/lms/static/sass/discussion/elements/_navigation.scss @@ -230,51 +230,6 @@ 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 { @include font-size(11); display: inline-block; diff --git a/lms/static/sass/discussion/utilities/_shame.scss b/lms/static/sass/discussion/utilities/_shame.scss index 83e1bfc17d..af92ff2e5a 100644 --- a/lms/static/sass/discussion/utilities/_shame.scss +++ b/lms/static/sass/discussion/utilities/_shame.scss @@ -136,3 +136,21 @@ li[class*=forum-nav-thread-label-] { 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; + } +} diff --git a/lms/static/sass/discussion/utilities/_variables.scss b/lms/static/sass/discussion/utilities/_variables.scss index 3c5f1a8b23..d9d296734c 100644 --- a/lms/static/sass/discussion/utilities/_variables.scss +++ b/lms/static/sass/discussion/utilities/_variables.scss @@ -1,5 +1,7 @@ $forum-color-active-thread: tint($blue, 85%); $forum-color-pinned: $pink; +$forum-color-reported: $pink; +$forum-color-closed: $black; $forum-color-following: $blue; $forum-color-staff: $blue; $forum-color-community-ta: $green-d1; diff --git a/lms/static/sass/discussion/views/_thread.scss b/lms/static/sass/discussion/views/_thread.scss index 3c5ab663ad..71a7da7a41 100644 --- a/lms/static/sass/discussion/views/_thread.scss +++ b/lms/static/sass/discussion/views/_thread.scss @@ -1,3 +1,117 @@ +// 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; @@ -6,7 +120,7 @@ body.discussion, .discussion-thread.expanded { .forum-thread-main-wrapper { - margin-bottom: $baseline; box-shadow: 0 1px 3px $shadow; } } + diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 3c413be4a9..14727e18f5 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -1,7 +1,8 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.template.defaultfilters import escapejs %> <%! from django_comment_client.permissions import has_permission %> - +## IMPORTANT: In order to keep js tests valid and relevant, please be sure to update the appropriate HTML in +## common/static/coffee/spec/discussion_spec_helper.coffee is changed and regenerated, whenever this one changes. @@ -156,57 +144,58 @@ @@ -296,16 +288,16 @@ js_block = u""" var labels = ""; if (pinned) {{ - labels += '
  1. {pinned_text}
  2. '; + labels += ' '; }} if (typeof(subscribed) != "undefined" && subscribed) {{ - labels += '
  3. {following_text}
  4. '; + labels += ' '; }} if (staff_authored) {{ - labels += '
  5. {staff_text}
  6. '; + labels += ' '; }} if (community_ta_authored) {{ - labels += '
  7. {community_ta_text}
  8. '; + labels += ' '; }} if (labels != "") {{ print('
      ' + labels + '
    '); @@ -554,3 +546,107 @@ + +<%def name="primaryAction(action_class, icon, sr_label, unchecked_label, checked_label)"> + + + +${primaryAction("endorse", "ok", _("Endorse"), _("Endorse"), _("Unendorse"))} +${primaryAction("answer", "ok", _("Mark as Answer"), _("Mark as Answer"), _("Unmark as Answer"))} +${primaryAction("follow", "star", _("Follow"), _("Follow"), _("Unfollow"))} + + + +<%def name="secondaryStateAction(action_class, icon, sr_label, unchecked_label, checked_label)"> + + + +${secondaryStateAction("report", "flag", _("Report abuse"), _("Report"), _("Unreport"))} +${secondaryStateAction("pin", "pushpin", _("Pin"), _("Pin"), _("Unpin"))} +${secondaryStateAction("close", "lock", _("Close"), _("Close"), _("Open"))} + +<%def name="secondaryAction(action_class, icon, label)"> + + + +${secondaryAction("edit", "pencil", _("Edit"))} +${secondaryAction("delete", "remove", _("Delete"))} + + + +