From 988e4e6da5bf5f8db1cb269a82cc272c6d73ed72 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 15 Aug 2014 15:34:56 -0400 Subject: [PATCH] Update UI for forum actions The actions are now consolidated in one location for each piece of content. Primary actions (vote, follow, endorse, mark as answer) are buttons, and secondary actions (pin, edit, delete, report, close) are in a menu. This also includes improved front-end error handling for the actions and significant test cleanup. Co-authored-by: jsa Co-authored-by: marco Co-authored-by: Frances Botsford Co-authored-by: Brian Talbot --- .../discussion/discussion_spec_helper.coffee | 602 ++++++++++++++++++ .../coffee/spec/discussion/utils_spec.coffee | 27 + .../view/discussion_content_view_spec.coffee | 36 +- .../discussion_thread_show_view_spec.coffee | 185 ++++-- .../view/discussion_thread_view_spec.coffee | 47 +- .../view/discussion_view_spec_helper.coffee | 125 ++-- .../response_comment_show_view_spec.coffee | 57 +- .../view/response_comment_view_spec.coffee | 24 +- .../thread_response_show_view_spec.coffee | 194 ++++-- .../view/thread_response_view_spec.coffee | 15 +- .../coffee/src/discussion/content.coffee | 16 +- .../static/coffee/src/discussion/utils.coffee | 14 +- .../views/discussion_content_view.coffee | 375 +++++++---- .../views/discussion_thread_show_view.coffee | 152 +---- .../views/discussion_thread_view.coffee | 15 +- .../views/response_comment_show_view.coffee | 63 +- .../views/thread_response_show_view.coffee | 88 +-- .../test/acceptance/pages/lms/discussion.py | 60 +- lms/static/sass/application-extend2.scss.mako | 4 +- lms/static/sass/base/_variables.scss | 3 + lms/static/sass/discussion/_discussion.scss | 373 +---------- lms/static/sass/discussion/_mixins.scss | 42 +- .../sass/discussion/elements/_actions.scss | 313 +++++++++ .../sass/discussion/elements/_labels.scss | 37 ++ .../sass/discussion/elements/_navigation.scss | 45 -- .../sass/discussion/utilities/_shame.scss | 18 + .../sass/discussion/utilities/_variables.scss | 2 + lms/static/sass/discussion/views/_thread.scss | 116 +++- .../discussion/_underscore_templates.html | 312 +++++---- 29 files changed, 2052 insertions(+), 1308 deletions(-) create mode 100644 common/static/coffee/spec/discussion/utils_spec.coffee create mode 100644 lms/static/sass/discussion/elements/_actions.scss create mode 100644 lms/static/sass/discussion/elements/_labels.scss 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"))} + + + +