diff --git a/common/djangoapps/edxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py index 67bc36e981..487c2fbcaf 100644 --- a/common/djangoapps/edxmako/shortcuts.py +++ b/common/djangoapps/edxmako/shortcuts.py @@ -77,32 +77,6 @@ def marketing_link_context_processor(request): ) -def header_footer_context_processor(request): - """ - A django context processor to pass feature flags through to all Django - Templates that are related to the display of the header and footer in - the edX platform. - """ - # TODO: ECOM-136 Remove this processor with the corresponding header and footer feature flags. - return dict( - [ - ("ENABLE_NEW_EDX_HEADER", settings.FEATURES.get("ENABLE_NEW_EDX_HEADER", False)), - ("ENABLE_NEW_EDX_FOOTER", settings.FEATURES.get("ENABLE_NEW_EDX_FOOTER", False)) - ] - ) - - -def open_source_footer_context_processor(request): - """ - Checks the site name to determine whether to use the edX.org footer or the Open Source Footer. - """ - return dict( - [ - ("IS_EDX_DOMAIN", settings.FEATURES.get('IS_EDX_DOMAIN', False)) - ] - ) - - def render_to_string(template_name, dictionary, context=None, namespace='main'): # see if there is an override template defined in the microsite diff --git a/common/djangoapps/edxmako/tests.py b/common/djangoapps/edxmako/tests.py index d65248a6be..5bde841d53 100644 --- a/common/djangoapps/edxmako/tests.py +++ b/common/djangoapps/edxmako/tests.py @@ -1,7 +1,6 @@ from mock import patch, Mock import unittest -import ddt from django.conf import settings from django.http import HttpResponse @@ -11,16 +10,11 @@ from django.test.client import RequestFactory from django.core.urlresolvers import reverse import edxmako.middleware from edxmako import add_lookup, LOOKUP -from edxmako.shortcuts import ( - marketing_link, - render_to_string, - header_footer_context_processor, - open_source_footer_context_processor -) +from edxmako.shortcuts import marketing_link, render_to_string from student.tests.factories import UserFactory from util.testing import UrlResetMixin -@ddt.ddt + class ShortcutsTests(UrlResetMixin, TestCase): """ Test the edxmako shortcuts file @@ -40,26 +34,6 @@ class ShortcutsTests(UrlResetMixin, TestCase): link = marketing_link('ABOUT') self.assertEquals(link, expected_link) - @ddt.data((True, True), (False, False), (False, True), (True, False)) - @ddt.unpack - def test_header_and_footer(self, header_setting, footer_setting): - with patch.dict('django.conf.settings.FEATURES', { - 'ENABLE_NEW_EDX_HEADER': header_setting, - 'ENABLE_NEW_EDX_FOOTER': footer_setting, - }): - result = header_footer_context_processor({}) - self.assertEquals(footer_setting, result.get('ENABLE_NEW_EDX_FOOTER')) - self.assertEquals(header_setting, result.get('ENABLE_NEW_EDX_HEADER')) - - @ddt.data(True, False) - @ddt.unpack - def test_edx_footer(self, expected_result): - with patch.dict('django.conf.settings.FEATURES', { - 'IS_EDX_DOMAIN': expected_result - }): - result = open_source_footer_context_processor({}) - self.assertEquals(expected_result, result.get('IS_EDX_DOMAIN')) - class AddLookupTests(TestCase): """ diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9965132302..2da6d459cb 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -46,7 +46,8 @@ from student.models import ( Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user, PasswordHistory, UserSignupSource + create_comments_service_user, PasswordHistory, UserSignupSource, + anonymous_id_for_user ) from student.forms import PasswordResetFormNoActive @@ -92,6 +93,9 @@ from util.password_policy_validators import ( from third_party_auth import pipeline, provider from xmodule.error_module import ErrorDescriptor +import analytics +from eventtracking import tracker + log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -381,6 +385,10 @@ def register_user(request, extra_context=None): 'username': '', } + # We save this so, later on, we can determine what course motivated a user's signup + # if they actually complete the registration process + request.session['registration_course_id'] = context['course_id'] + if extra_context is not None: context.update(extra_context) @@ -951,6 +959,31 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un if LoginFailures.is_feature_enabled(): LoginFailures.clear_lockout_counter(user) + # Track the user's sign in + if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): + tracking_context = tracker.get_tracker().resolve_context() + analytics.identify(anonymous_id_for_user(user, None), { + 'email': email, + 'username': username, + }) + + # If the user entered the flow via a specific course page, we track that + registration_course_id = request.session.get('registration_course_id') + analytics.track( + user.id, + "edx.bi.user.account.authenticated", + { + 'category': "conversion", + 'label': registration_course_id + }, + context={ + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + request.session['registration_course_id'] = None + if user is not None and user.is_active: try: # We do not log here, because we have a handler registered @@ -1398,6 +1431,33 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many (user, profile, registration) = ret dog_stats_api.increment("common.student.account_created") + + email = post_vars['email'] + + # Track the user's registration + if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): + tracking_context = tracker.get_tracker().resolve_context() + analytics.identify(anonymous_id_for_user(user, None), { + email: email, + username: username, + }) + + registration_course_id = request.session.get('registration_course_id') + analytics.track( + user.id, + "edx.bi.user.account.registered", + { + "category": "conversion", + "label": registration_course_id + }, + context={ + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + request.session['registration_course_id'] = None + create_comments_service_user(user) context = { diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 4067db832f..d398e1584c 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -4,7 +4,7 @@ Loaded by Django's settings mechanism. Consequently, this module must not invoke the Django armature. """ -from social.backends import google, linkedin +from social.backends import google, linkedin, facebook _DEFAULT_ICON_CLASS = 'icon-signin' @@ -150,6 +150,26 @@ class LinkedInOauth2(BaseProvider): return provider_details.get('fullname') +class FacebookOauth2(BaseProvider): + """Provider for LinkedIn's Oauth2 auth system.""" + + BACKEND_CLASS = facebook.FacebookOAuth2 + ICON_CLASS = 'icon-facebook' + NAME = 'Facebook' + SETTINGS = { + 'SOCIAL_AUTH_FACEBOOK_KEY': None, + 'SOCIAL_AUTH_FACEBOOK_SECRET': None, + } + + @classmethod + def get_email(cls, provider_details): + return provider_details.get('email') + + @classmethod + def get_name(cls, provider_details): + return provider_details.get('fullname') + + class Registry(object): """Singleton registry of third-party auth providers. diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 4eb40fff3d..0ba253c27e 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -282,7 +282,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): def assert_register_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /register not in the pipeline looks correct.""" self.assertEqual(200, response.status_code) - self.assertIn('Sign in with ' + self.PROVIDER_CLASS.NAME, response.content) + self.assertIn('Sign up with ' + self.PROVIDER_CLASS.NAME, response.content) self.assert_signin_button_looks_functional(response.content, pipeline.AUTH_ENTRY_REGISTER) def assert_signin_button_looks_functional(self, content, auth_entry): diff --git a/common/static/coffee/spec/discussion/content_spec.coffee b/common/static/coffee/spec/discussion/content_spec.coffee index 8f03349393..77b8fd41e2 100644 --- a/common/static/coffee/spec/discussion/content_spec.coffee +++ b/common/static/coffee/spec/discussion/content_spec.coffee @@ -41,11 +41,11 @@ describe 'All Content', -> it 'can update info', -> @content.updateInfo { - ability: 'can_endorse', + ability: {'can_edit': true}, voted: true, subscribed: true } - expect(@content.get 'ability').toEqual 'can_endorse' + expect(@content.get 'ability').toEqual {'can_edit': true} expect(@content.get 'voted').toEqual true expect(@content.get 'subscribed').toEqual true @@ -77,3 +77,39 @@ describe 'All Content', -> myComments = new Comments myComments.add @comment1 expect(myComments.find('123')).toBe @comment1 + + it 'can be endorsed', -> + + DiscussionUtil.loadRoles( + {"Moderator": [111], "Administrator": [222], "Community TA": [333]} + ) + @discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99}) + @discussionResponse = new Comment({id: 1, thread: @discussionThread}) + @questionThread = new Thread({id: 1, thread_type: "question", user_id: 99}) + @questionResponse = new Comment({id: 1, thread: @questionThread}) + + # mod + window.user = new DiscussionUser({id: 111}) + expect(@discussionResponse.canBeEndorsed()).toBe(true) + expect(@questionResponse.canBeEndorsed()).toBe(true) + + # admin + window.user = new DiscussionUser({id: 222}) + expect(@discussionResponse.canBeEndorsed()).toBe(true) + expect(@questionResponse.canBeEndorsed()).toBe(true) + + # TA + window.user = new DiscussionUser({id: 333}) + expect(@discussionResponse.canBeEndorsed()).toBe(true) + expect(@questionResponse.canBeEndorsed()).toBe(true) + + # thread author + window.user = new DiscussionUser({id: 99}) + expect(@discussionResponse.canBeEndorsed()).toBe(false) + expect(@questionResponse.canBeEndorsed()).toBe(true) + + # anyone else + window.user = new DiscussionUser({id: 999}) + expect(@discussionResponse.canBeEndorsed()).toBe(false) + expect(@questionResponse.canBeEndorsed()).toBe(false) + diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee index fc7286e92c..2863658b9c 100644 --- a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee +++ b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee @@ -3,4 +3,606 @@ class @DiscussionSpecHelper @setUpGlobals = -> DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []}) window.$$course_id = "edX/999/test" - window.user = new DiscussionUser({id: "567", upvoted_ids: []}) + window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []}) + DiscussionUtil.setUser(window.user) + + @makeModerator = () -> + DiscussionUtil.roleIds["Moderator"].push(parseInt(window.user.id)) + + @setUnderscoreFixtures = -> + appendSetFixtures(""" +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""") 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_list_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee index ffb6c1b84b..06d2a7783f 100644 --- a/common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee @@ -6,7 +6,23 @@ describe "DiscussionThreadListView", -> - -
- """ - ) - - @threadData = { - id: "dummy", - body: "dummy body", - abuse_flaggers: [], - votes: {up_count: "42"} - } - @thread = new Thread(@threadData) - @view = new DiscussionThreadInlineView({ model: @thread }) - @view.setElement($(".thread-fixture")) - spyOn($, "ajax") - # Avoid unnecessary boilerplate - spyOn(@view.showView, "render") - spyOn(@view.showView, "convertMath") - spyOn(@view, "makeWmdEditor") - spyOn(DiscussionThreadView.prototype, "renderResponse") - - assertContentVisible = (view, selector, visible) -> - content = view.$el.find(selector) - expect(content.length).toEqual(1) - expect(content.is(":visible")).toEqual(visible) - - assertExpandedContentVisible = (view, expanded) -> - expect(view.$el.hasClass("expanded")).toEqual(expanded) - assertContentVisible(view, ".post-extended-content", expanded) - assertContentVisible(view, ".expand-post", not expanded) - assertContentVisible(view, ".collapse-post", expanded) - - describe "render", -> - it "uses the cohorted template if cohorted", -> - @view.model.set({group_id: 1}) - @view.render() - expect(@view.$el.find(".cohorted-indicator").length).toEqual(1) - - it "uses the non-cohorted template if not cohorted", -> - @view.render() - expect(@view.$el.find(".non-cohorted-indicator").length).toEqual(1) - - it "shows content that should be visible when collapsed", -> - @view.render() - assertExpandedContentVisible(@view, false) - - it "does not render any responses by default", -> - @view.render() - expect($.ajax).not.toHaveBeenCalled() - expect(@view.$el.find(".responses li").length).toEqual(0) - - describe "expand/collapse", -> - it "shows/hides appropriate content", -> - DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []}) - @view.render() - @view.expandPost() - assertExpandedContentVisible(@view, true) - @view.collapsePost() - assertExpandedContentVisible(@view, false) - - it "switches between the abbreviated and full body", -> - DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []}) - @thread.set("body", new Array(100).join("test ")) - @view.abbreviateBody() - expect(@thread.get("body")).not.toEqual(@thread.get("abbreviatedBody")) - @view.render() - @view.expandPost() - expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("body")) - expect(@view.showView.convertMath).toHaveBeenCalled() - @view.showView.convertMath.reset() - @view.collapsePost() - expect(@view.$el.find(".post-body").text()).toEqual(@thread.get("abbreviatedBody")) - expect(@view.showView.convertMath).toHaveBeenCalled() 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 31a883cb31..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,80 +1,188 @@ describe "DiscussionThreadView", -> beforeEach -> - setFixtures( - """ - -
- """ - ) + DiscussionSpecHelper.setUpGlobals() + DiscussionSpecHelper.setUnderscoreFixtures() jasmine.Clock.useMock() - @threadData = { - id: "dummy" - } + @threadData = DiscussionViewSpecHelper.makeThreadWithProps({}) @thread = new Thread(@threadData) - @view = new DiscussionThreadView({ model: @thread }) - @view.setElement($(".thread-fixture")) spyOn($, "ajax") # Avoid unnecessary boilerplate - spyOn(@view.showView, "render") - spyOn(@view, "makeWmdEditor") - spyOn(DiscussionThreadView.prototype, "renderResponse") + spyOn(DiscussionThreadShowView.prototype, "convertMath") + spyOn(DiscussionContentView.prototype, "makeWmdEditor") + spyOn(ThreadResponseView.prototype, "renderShowView") - describe "response count and pagination", -> - renderWithContent = (view, content) -> - DiscussionViewSpecHelper.setNextResponseContent(content) - view.render() - jasmine.Clock.tick(100) + renderWithContent = (view, content) -> + DiscussionViewSpecHelper.setNextResponseContent(content) + view.render() + jasmine.Clock.tick(100) - assertRenderedCorrectly = (view, countText, displayCountText, buttonText) -> - expect(view.$el.find(".response-count").text()).toEqual(countText) - if displayCountText - expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText) - else - expect(view.$el.find(".response-display-count").length).toEqual(0) - if buttonText - expect(view.$el.find(".load-response-button").text()).toEqual(buttonText) - else - expect(view.$el.find(".load-response-button").length).toEqual(0) + assertContentVisible = (view, selector, visible) -> + content = view.$el.find(selector) + expect(content.length).toBeGreaterThan(0) + content.each (i, elem) -> + expect($(elem).is(":visible")).toEqual(visible) - it "correctly render for a thread with no responses", -> - renderWithContent(@view, {resp_total: 0, children: []}) - assertRenderedCorrectly(@view, "0 responses", null, null) + assertExpandedContentVisible = (view, expanded) -> + expect(view.$el.hasClass("expanded")).toEqual(expanded) + assertContentVisible(view, ".post-extended-content", expanded) + assertContentVisible(view, ".forum-thread-expand", not expanded) + assertContentVisible(view, ".forum-thread-collapse", expanded) - it "correctly render for a thread with one response", -> - renderWithContent(@view, {resp_total: 1, children: [{}]}) - assertRenderedCorrectly(@view, "1 response", "Showing all responses", null) + assertResponseCountAndPaginationCorrect = (view, countText, displayCountText, buttonText) -> + expect(view.$el.find(".response-count").text()).toEqual(countText) + if displayCountText + expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText) + else + expect(view.$el.find(".response-display-count").length).toEqual(0) + if buttonText + expect(view.$el.find(".load-response-button").text()).toEqual(buttonText) + else + expect(view.$el.find(".load-response-button").length).toEqual(0) - it "correctly render for a thread with one additional page", -> - renderWithContent(@view, {resp_total: 2, children: [{}]}) - assertRenderedCorrectly(@view, "2 responses", "Showing first response", "Load all responses") + describe "tab mode", -> + beforeEach -> + @view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "tab"}) - it "correctly render for a thread with multiple additional pages", -> - renderWithContent(@view, {resp_total: 111, children: [{}, {}]}) - assertRenderedCorrectly(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses") + describe "response count and pagination", -> + it "correctly render for a thread with no responses", -> + renderWithContent(@view, {resp_total: 0, children: []}) + assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null) - describe "on clicking the load more button", -> - beforeEach -> - renderWithContent(@view, {resp_total: 5, children: [{}]}) - assertRenderedCorrectly(@view, "5 responses", "Showing first response", "Load all responses") - - it "correctly re-render when all threads have loaded", -> - DiscussionViewSpecHelper.setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]}) - @view.$el.find(".load-response-button").click() - assertRenderedCorrectly(@view, "5 responses", "Showing all responses", null) + it "correctly render for a thread with one response", -> + renderWithContent(@view, {resp_total: 1, children: [{}]}) + assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null) - it "correctly re-render when one page remains", -> - DiscussionViewSpecHelper.setNextResponseContent({resp_total: 42, children: [{}, {}]}) - @view.$el.find(".load-response-button").click() - assertRenderedCorrectly(@view, "42 responses", "Showing first 3 responses", "Load all responses") + it "correctly render for a thread with one additional page", -> + renderWithContent(@view, {resp_total: 2, children: [{}]}) + assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses") - it "correctly re-render when multiple pages remain", -> - DiscussionViewSpecHelper.setNextResponseContent({resp_total: 111, children: [{}, {}]}) - @view.$el.find(".load-response-button").click() - assertRenderedCorrectly(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses") + it "correctly render for a thread with multiple additional pages", -> + renderWithContent(@view, {resp_total: 111, children: [{}, {}]}) + assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses") + + describe "on clicking the load more button", -> + beforeEach -> + renderWithContent(@view, {resp_total: 5, children: [{}]}) + assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses") + + it "correctly re-render when all threads have loaded", -> + DiscussionViewSpecHelper.setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]}) + @view.$el.find(".load-response-button").click() + assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null) + + it "correctly re-render when one page remains", -> + DiscussionViewSpecHelper.setNextResponseContent({resp_total: 42, children: [{}, {}]}) + @view.$el.find(".load-response-button").click() + assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses") + + it "correctly re-render when multiple pages remain", -> + DiscussionViewSpecHelper.setNextResponseContent({resp_total: 111, children: [{}, {}]}) + @view.$el.find(".load-response-button").click() + assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses") + + describe "inline mode", -> + beforeEach -> + @view = new DiscussionThreadView({ model: @thread, el: $("#fixture-element"), mode: "inline"}) + + describe "render", -> + it "shows content that should be visible when collapsed", -> + @view.render() + assertExpandedContentVisible(@view, false) + + it "does not render any responses by default", -> + @view.render() + expect($.ajax).not.toHaveBeenCalled() + expect(@view.$el.find(".responses li").length).toEqual(0) + + describe "expand/collapse", -> + it "shows/hides appropriate content", -> + DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []}) + @view.render() + @view.expand() + assertExpandedContentVisible(@view, true) + @view.collapse() + assertExpandedContentVisible(@view, false) + + it "switches between the abbreviated and full body", -> + DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []}) + longBody = new Array(100).join("test ") + expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140) + @thread.set("body", longBody) + + @view.render() + expect($(".post-body").text()).toEqual(expectedAbbreviation) + expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled() + DiscussionThreadShowView.prototype.convertMath.reset() + + @view.expand() + expect($(".post-body").text()).toEqual(longBody) + expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled() + DiscussionThreadShowView.prototype.convertMath.reset() + + @view.collapse() + expect($(".post-body").text()).toEqual(expectedAbbreviation) + expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled() + + describe "for question threads", -> + beforeEach -> + @thread.set("thread_type", "question") + @view = new DiscussionThreadView( + {model: @thread, el: $("#fixture-element"), mode: "tab"} + ) + + renderTestCase = (view, numEndorsed, numNonEndorsed) -> + generateContent = (idStart, idEnd) -> + _.map(_.range(idStart, idEnd), (i) -> {"id": "#{i}"}) + renderWithContent( + view, + { + endorsed_responses: generateContent(0, numEndorsed), + non_endorsed_responses: generateContent(numEndorsed, numEndorsed + numNonEndorsed), + non_endorsed_resp_total: numNonEndorsed + } + ) + expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed) + expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed) + assertResponseCountAndPaginationCorrect( + view, + ngettext( + "#{numNonEndorsed} #{if numEndorsed then "other " else ""}response", + "#{numNonEndorsed} #{if numEndorsed then "other " else ""}responses", + numNonEndorsed + ) + if numNonEndorsed then "Showing all responses" else null, + null + ) + + _.each({"no": 0, "one": 1, "many": 5}, (numEndorsed, endorsedDesc) -> + _.each({"no": 0, "one": 1, "many": 5}, (numNonEndorsed, nonEndorsedDesc) -> + it "renders correctly with #{endorsedDesc} marked answer(s) and #{nonEndorsedDesc} response(s)", -> + renderTestCase(@view, numEndorsed, numNonEndorsed) + ) + ) + + it "handles pagination correctly", -> + renderWithContent( + @view, + { + endorsed_responses: [{id: "1"}, {id: "2"}], + non_endorsed_responses: [{id: "3"}, {id: "4"}, {id: "5"}], + non_endorsed_resp_total: 42 + } + ) + DiscussionViewSpecHelper.setNextResponseContent({ + # Add an endorsed response; it should be rendered + endorsed_responses: [{id: "1"}, {id: "2"}, {id: "6"}], + non_endorsed_responses: [{id: "7"}, {id: "8"}, {id: "9"}], + non_endorsed_resp_total: 41 + }) + @view.$el.find(".load-response-button").click() + expect($(".js-marked-answer-list .discussion-response").length).toEqual(3) + expect($(".js-response-list .discussion-response").length).toEqual(6) + assertResponseCountAndPaginationCorrect( + @view, + "41 other responses", + "Showing first 6 responses", + "Load all responses" + ) 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 45a97f3175..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 @@ -1,101 +1,63 @@ class @DiscussionViewSpecHelper - @expectVoteRendered = (view, voted) -> - button = view.$el.find(".vote-btn") - if voted - expect(button.hasClass("is-cast")).toBe(true) - expect(button.attr("aria-pressed")).toEqual("true") - expect(button.attr("data-tooltip")).toEqual("remove vote") - expect(button.text()).toEqual("43 votes (click to remove your vote)") - else - expect(button.hasClass("is-cast")).toBe(false) - expect(button.attr("aria-pressed")).toEqual("false") - expect(button.attr("data-tooltip")).toEqual("vote") - expect(button.text()).toEqual("42 votes (click to vote)") + @makeThreadWithProps = (props) -> + # Minimal set of properties necessary for rendering + thread = { + id: "dummy_id", + thread_type: "discussion", + pinned: false, + endorsed: false, + votes: {up_count: '0'}, + unread_comments_count: 0, + comments_count: 0, + abuse_flaggers: [], + body: "", + title: "dummy title", + created_at: "2014-08-18T01:02:03Z" + } + $.extend(thread, props) + + @expectVoteRendered = (view, model, user) -> + button = view.$el.find(".action-vote") + expect(button.hasClass("is-checked")).toBe(user.voted(model)) + expect(button.attr("aria-checked")).toEqual(user.voted(model).toString()) + expect(button.find(".js-visual-vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$") + expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^currently #{model.get('votes').up_count} votes?$") @checkRenderVote = (view, model) -> - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, false) + view.render() + DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user) window.user.vote(model) - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, true) + view.render() + DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user) window.user.unvote(model) - view.renderVote() - DiscussionViewSpecHelper.expectVoteRendered(view, false) - - @checkVote = (view, model, modelData, checkRendering) -> - view.renderVote() - if checkRendering - DiscussionViewSpecHelper.expectVoteRendered(view, false) + 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) @@ -111,7 +73,7 @@ class @DiscussionViewSpecHelper expect(spy).toHaveBeenCalled() @checkVoteButtonEvents = (view) -> - @checkButtonEvents(view, "toggleVote", ".vote-btn") + @checkButtonEvents(view, "toggleVote", ".action-vote") @setNextResponseContent = (content) -> $.ajax.andCallFake( diff --git a/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee b/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee index 7cea95b0e3..fe98c09cd0 100644 --- a/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee @@ -3,101 +3,53 @@ describe "NewPostView", -> beforeEach -> setFixtures( """ -
-
-
-
-
-
+
+
+
-
+
- - - - - - - - - """ ) window.$$course_id = "edX/999/test" @@ -106,30 +58,31 @@ describe "NewPostView", -> describe "Drop down works correct", -> beforeEach -> + @course_settings = new DiscussionCourseSettings({ + "category_map": { + "subcategories": { + "Basic Question Types": { + "subcategories": {}, + "children": ["Selection From Options"], + "entries": { + "Selection From Options": { + "sort_key": null, + "is_cohorted": true, + "id": "cba3e4cd91d0466b9ac50926e495b76f" + } + }, + }, + }, + "children": ["Basic Question Types"], + "entries": {} + }, + "allow_anonymous": true, + "allow_anonymous_to_peers": true + }) @view = new NewPostView( el: $(".new-post-article"), collection: @discussion, - course_settings: new DiscussionCourseSettings({ - "category_map": { - "subcategories": { - "Basic Question Types": { - "subcategories": {}, - "children": ["Selection From Options"], - "entries": { - "Selection From Options": { - "sort_key": null, - "is_cohorted": true, - "id": "cba3e4cd91d0466b9ac50926e495b76f" - } - }, - }, - }, - "children": ["Basic Question Types"], - "entries": {} - }, - "allow_anonymous": true, - "allow_anonymous_to_peers": true - }), + course_settings: @course_settings, mode: "tab" ) @view.render() @@ -140,16 +93,16 @@ describe "NewPostView", -> complete_text = @parent_category_text + " / " + @selected_option_text selected_text_width = @view.getNameWidth(complete_text) @view.maxNameWidth = selected_text_width + 1 - @view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click() - dropdown_text = @view.$el.find(".form-topic-drop > a").text() - expect(complete_text+' ▾').toEqual(dropdown_text) + @view.$el.find( "a.topic-title" ).first().click() + dropdown_text = @view.$el.find(".js-selected-topic").text() + expect(complete_text).toEqual(dropdown_text) it "completely show just sub-category", -> complete_text = @parent_category_text + " / " + @selected_option_text selected_text_width = @view.getNameWidth(complete_text) @view.maxNameWidth = selected_text_width - 10 - @view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click() - dropdown_text = @view.$el.find(".form-topic-drop > a").text() + @view.$el.find( "a.topic-title" ).first().click() + dropdown_text = @view.$el.find(".js-selected-topic").text() expect(dropdown_text.indexOf("…")).toEqual(0) expect(dropdown_text).toContain(@selected_option_text) @@ -158,8 +111,8 @@ describe "NewPostView", -> complete_text = @parent_category_text + " / " + @selected_option_text selected_text_width = @view.getNameWidth(complete_text) @view.maxNameWidth = selected_text_width - parent_width - @view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click() - dropdown_text = @view.$el.find(".form-topic-drop > a").text() + @view.$el.find( "a.topic-title" ).first().click() + dropdown_text = @view.$el.find(".js-selected-topic").text() expect(dropdown_text.indexOf("…")).toEqual(0) expect(dropdown_text.lastIndexOf("…")).toBeGreaterThan(0) @@ -167,10 +120,49 @@ describe "NewPostView", -> complete_text = @parent_category_text + " / " + @selected_option_text selected_text_width = @view.getNameWidth(complete_text) @view.maxNameWidth = @view.getNameWidth(@selected_option_text) + 100 - @view.$el.find( "ul.topic_menu li[role='menuitem'] > a" )[1].click() - dropdown_text = @view.$el.find(".form-topic-drop > a").text() + @view.$el.find( "a.topic-title" ).first().click() + dropdown_text = @view.$el.find(".js-selected-topic").text() expect(dropdown_text.indexOf("/ span>")).toEqual(-1) + describe "cohort selector", -> + renderWithCohortedTopics = (course_settings, view, isCohortedFirst) -> + course_settings.set( + "category_map", + { + "children": if isCohortedFirst then ["Cohorted", "Non-Cohorted"] else ["Non-Cohorted", "Cohorted"], + "entries": { + "Non-Cohorted": { + "sort_key": null, + "is_cohorted": false, + "id": "non-cohorted" + }, + "Cohorted": { + "sort_key": null, + "is_cohorted": true, + "id": "cohorted" + } + } + } + ) + view.render() + + expectCohortSelectorEnabled = (view, enabled) -> + expect(view.$(".js-group-select").prop("disabled")).toEqual(not enabled) + if not enabled + expect(view.$(".js-group-select option:selected").attr("value")).toEqual("") + + it "is disabled with non-cohorted default topic and enabled by selecting cohorted topic", -> + renderWithCohortedTopics(@course_settings, @view, false) + expectCohortSelectorEnabled(@view, false) + @view.$("a.topic-title[data-discussion-id=cohorted]").click() + expectCohortSelectorEnabled(@view, true) + + it "is enabled with cohorted default topic and disabled by selecting non-cohorted topic", -> + renderWithCohortedTopics(@course_settings, @view, true) + expectCohortSelectorEnabled(@view, true) + @view.$("a.topic-title[data-discussion-id=non-cohorted]").click() + expectCohortSelectorEnabled(@view, false) + it "posts to the correct URL", -> topicId = "test_topic" spyOn($, "ajax").andCallFake( @@ -189,5 +181,5 @@ describe "NewPostView", -> topicId: topicId ) view.render() - view.$(".new-post-form").submit() + view.$(".forum-new-post-form").submit() expect($.ajax).toHaveBeenCalled() 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 21b0c300f0..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,40 +1,220 @@ describe "ThreadResponseShowView", -> beforeEach -> DiscussionSpecHelper.setUpGlobals() - setFixtures( - """ -
    - - 0 votes (click to vote) - -
    - """ - ) + DiscussionSpecHelper.setUnderscoreFixtures() + @user = DiscussionUtil.getUser() + @thread = new Thread({"thread_type": "discussion"}) @commentData = { id: "dummy", user_id: "567", course_id: "TestOrg/TestCourse/TestRun", body: "this is a comment", created_at: "2013-04-03T20:08:39Z", + endorsed: false, abuse_flaggers: [], - votes: {up_count: "42"} + votes: {up_count: 42}, + type: "comment" } @comment = new Comment(@commentData) - @view = new ThreadResponseShowView({ model: @comment }) - @view.setElement($(".discussion-post")) + @comment.set("thread", @thread) + @view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") }) - it "renders the vote correctly", -> - DiscussionViewSpecHelper.checkRenderVote(@view, @comment) + # Avoid unnecessary boilerplate + spyOn(ThreadResponseShowView.prototype, "convertMath") - it "votes correctly", -> - DiscussionViewSpecHelper.checkVote(@view, @comment, @commentData, true) + @view.render() - it "unvotes correctly", -> - DiscussionViewSpecHelper.checkUnvote(@view, @comment, @commentData, true) + describe "voting", -> - it 'toggles the vote correctly', -> - DiscussionViewSpecHelper.checkToggleVote(@view, @comment) + it "renders the vote state correctly", -> + DiscussionViewSpecHelper.checkRenderVote(@view, @comment) - it "vote button activates on appropriate events", -> - DiscussionViewSpecHelper.checkVoteButtonEvents(@view) + it "votes correctly via click", -> + DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click")) + + it "votes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32})) + + it "unvotes correctly via click", -> + DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click")) + + it "unvotes correctly via spacebar", -> + DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32})) + + it "renders endorsement correctly for a marked answer in a question thread", -> + endorsement = { + "username": "test_endorser", + "time": new Date().toISOString() + } + @thread.set("thread_type", "question") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch( + "marked as answer less than a minute ago by " + endorsement.username + ) + + it "renders anonymous endorsement correctly for a marked answer in a question thread", -> + endorsement = { + "username": null, + "time": new Date().toISOString() + } + @thread.set("thread_type", "question") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago") + expect(@view.$(".posted-details").text()).not.toMatch("\sby\s") + + it "renders endorsement correctly for an endorsed response in a discussion thread", -> + endorsement = { + "username": "test_endorser", + "time": new Date().toISOString() + } + @thread.set("thread_type", "discussion") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch( + "endorsed less than a minute ago by " + endorsement.username + ) + + it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", -> + endorsement = { + "username": null, + "time": new Date().toISOString() + } + @thread.set("thread_type", "discussion") + @comment.set({ + "endorsed": true, + "endorsement": endorsement + }) + @view.render() + expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago") + expect(@view.$(".posted-details").text()).not.toMatch("\sby\s") + + it "re-renders correctly when endorsement changes", -> + DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]}) + @thread.set("thread_type", "question") + @view.render() + expect(@view.$(".posted-details").text()).not.toMatch("marked as answer") + @view.$(".action-answer").click() + expect(@view.$(".posted-details").text()).toMatch("marked as answer") + @view.$(".action-answer").click() + expect(@view.$(".posted-details").text()).not.toMatch("marked as answer") + + it "allows a moderator to mark an answer in a question thread", -> + DiscussionUtil.loadRoles({"Moderator": parseInt(window.user.id)}) + @thread.set({ + "thread_type": "question", + "user_id": (parseInt(window.user.id) + 1).toString() + }) + @view.render() + endorseButton = @view.$(".action-answer") + expect(endorseButton.length).toEqual(1) + expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden") + endorseButton.click() + expect(endorseButton).toHaveClass("is-checked") + + it "allows the author of a question thread to mark an answer", -> + @thread.set({ + "thread_type": "question", + "user_id": window.user.id + }) + @view.render() + endorseButton = @view.$(".action-answer") + expect(endorseButton.length).toEqual(1) + expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden") + endorseButton.click() + expect(endorseButton).toHaveClass("is-checked") + + it "does not allow the author of a discussion thread to endorse", -> + @thread.set({ + "thread_type": "discussion", + "user_id": window.user.id + }) + @view.render() + endorseButton = @view.$(".action-endorse") + expect(endorseButton.length).toEqual(1) + expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden") + + it "does not allow a student who is not the author of a question thread to mark an answer", -> + @thread.set({ + "thread_type": "question", + "user_id": (parseInt(window.user.id) + 1).toString() + }) + @view.render() + endorseButton = @view.$(".action-answer") + expect(endorseButton.length).toEqual(1) + expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden") + + describe "labels", -> + + expectOneElement = (view, selector, visible=true) => + view.render() + elements = view.$el.find(selector) + expect(elements.length).toEqual(1) + if visible + expect(elements).not.toHaveClass("is-hidden") + else + expect(elements).toHaveClass("is-hidden") + + it 'displays the reported label when appropriate for a non-staff user', -> + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should not be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported', false) + + it 'displays the reported label when appropriate for a flag moderator', -> + DiscussionSpecHelper.makeModerator() + expectOneElement(@view, '.post-label-reported', false) + # flagged by current user - should be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id]) + expectOneElement(@view, '.post-label-reported') + # flagged by some other user but not the current one - should still be labelled + @comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]) + expectOneElement(@view, '.post-label-reported') + + describe "endorser display", -> + + beforeEach -> + @comment.set('endorsement', { + "username": "test_endorser", + "time": new Date().toISOString() + }) + spyOn(DiscussionUtil, 'urlFor').andReturn('test_endorser_url') + + checkUserLink = (element, is_ta, is_staff) -> + expect(element.find('a.username').length).toEqual(1) + expect(element.find('a.username').text()).toEqual('test_endorser') + expect(element.find('a.username').attr('href')).toEqual('test_endorser_url') + expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0) + expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0) + + it "renders nothing when the response has not been endorsed", -> + @comment.set('endorsement', null) + expect(@view.getEndorserDisplay()).toBeNull() + + it "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 9ae1b00dd5..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,19 +1,43 @@ describe 'ThreadResponseView', -> beforeEach -> - setFixtures """ - -
    - """ + DiscussionSpecHelper.setUpGlobals() + DiscussionSpecHelper.setUnderscoreFixtures() + @response = new Comment { children: [{}, {}] } - @view = new ThreadResponseView({model: @response, el: $("#thread-response-fixture")}) + @view = new ThreadResponseView({model: @response, el: $("#fixture-element")}) spyOn(ThreadResponseShowView.prototype, "render") spyOn(ResponseCommentView.prototype, "render") describe 'renderComments', -> + it 'hides "show comments" link if collapseComments is not set', -> + @view.render() + expect(@view.$(".comments")).toBeVisible() + expect(@view.$(".action-show-comments")).not.toBeVisible() + + it 'hides "show comments" link if collapseComments is set but response has no comments', -> + @response = new Comment { children: [] } + @view = new ThreadResponseView({ + model: @response, el: $("#fixture-element"), + collapseComments: true + }) + @view.render() + expect(@view.$(".comments")).toBeVisible() + expect(@view.$(".action-show-comments")).not.toBeVisible() + + it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', -> + @view = new ThreadResponseView({ + model: @response, el: $("#fixture-element"), + collapseComments: true + }) + @view.render() + expect(@view.$(".comments")).not.toBeVisible() + expect(@view.$(".action-show-comments")).toBeVisible() + @view.$(".action-show-comments").click() + expect(@view.$(".comments")).toBeVisible() + expect(@view.$(".action-show-comments")).not.toBeVisible() + it 'populates commentViews and binds events', -> # Ensure that edit view is set to test invocation of cancelEdit @view.createEditView() diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/coffee/src/discussion/content.coffee index c89f5e1d2e..ecf2f2f733 100644 --- a/common/static/coffee/src/discussion/content.coffee +++ b/common/static/coffee/src/discussion/content.coffee @@ -9,7 +9,6 @@ if Backbone? actions: editable: '.admin-edit' can_reply: '.discussion-reply' - can_endorse: '.admin-endorse' can_delete: '.admin-delete' can_openclose: '.admin-openclose' @@ -21,6 +20,9 @@ if Backbone? can: (action) -> (@get('ability') || {})[action] + # Default implementation + canBeEndorsed: -> false + updateInfo: (info) -> if info @set('ability', info.ability) @@ -106,13 +108,21 @@ if Backbone? @get("abuse_flaggers").pop(window.user.get('id')) @trigger "change", @ + isFlagged: -> + user = DiscussionUtil.getUser() + flaggers = @get("abuse_flaggers") + user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0)) + + incrementVote: (increment) -> + newVotes = _.clone(@get("votes")) + newVotes.up_count = newVotes.up_count + increment + @set("votes", newVotes) + vote: -> - @get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1 - @trigger "change", @ + @incrementVote(1) unvote: -> - @get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1 - @trigger "change", @ + @incrementVote(-1) class @Thread extends @Content urlMappers: @@ -187,6 +197,13 @@ if Backbone? count += comment.getCommentsCount() + 1 count + canBeEndorsed: => + user_id = window.user.get("id") + user_id && ( + DiscussionUtil.isPrivilegedUser(user_id) || + (@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id) + ) + class @Comments extends Backbone.Collection model: Comment diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/coffee/src/discussion/discussion.coffee index 8b6162f54b..fb16c3ac0d 100644 --- a/common/static/coffee/src/discussion/discussion.coffee +++ b/common/static/coffee/src/discussion/discussion.coffee @@ -34,6 +34,8 @@ if Backbone? retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> data = { page: @current_page + 1 } + if _.contains(["unread", "unanswered", "flagged"], options.filter) + data[options.filter] = true switch mode when 'search' url = DiscussionUtil.urlFor 'search' @@ -43,9 +45,6 @@ if Backbone? data['commentable_ids'] = options.commentable_ids when 'all' url = DiscussionUtil.urlFor 'threads' - when 'flagged' - data['flagged'] = true - url = DiscussionUtil.urlFor 'search' when 'followed' url = DiscussionUtil.urlFor 'followed_threads', options.user_id if options['group_id'] diff --git a/common/static/coffee/src/discussion/discussion_filter.coffee b/common/static/coffee/src/discussion/discussion_filter.coffee index 6b3ab03689..f9cc710e0d 100644 --- a/common/static/coffee/src/discussion/discussion_filter.coffee +++ b/common/static/coffee/src/discussion/discussion_filter.coffee @@ -1,8 +1,13 @@ class @DiscussionFilter + + # TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics + # for use with a very similar category dropdown in the New Post form. The two menus' implementations + # should be merged into a single reusable view. + @filterDrop: (e) -> - $drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper') + $drop = $(e.target).parents('.topic-menu-wrapper') query = $(e.target).val() - $items = $drop.find('a') + $items = $drop.find('.topic-menu-item') if(query.length == 0) $items.removeClass('hidden') @@ -10,19 +15,14 @@ class @DiscussionFilter $items.addClass('hidden') $items.each (i) -> - thisText = $(this).not('.unread').text() - $(this).parents('ul').siblings('a').not('.unread').each (i) -> - thisText = thisText + ' ' + $(this).text(); - test = true - terms = thisText.split(' ') + path = $(this).parents(".topic-menu-item").andSelf() + pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get() + pathText = pathTitles.join(" / ").toLowerCase() - if(thisText.toLowerCase().search(query.toLowerCase()) == -1) - test = false - - if(test) + if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1) $(this).removeClass('hidden') # show children - $(this).parent().find('a').removeClass('hidden'); + $(this).find('.topic-menu-item').removeClass('hidden'); # show parents - $(this).parents('ul').siblings('a').removeClass('hidden'); + $(this).parents('.topic-menu-item').removeClass('hidden'); diff --git a/common/static/coffee/src/discussion/discussion_module_view.coffee b/common/static/coffee/src/discussion/discussion_module_view.coffee index 811305037b..741276e6b2 100644 --- a/common/static/coffee/src/discussion/discussion_module_view.coffee +++ b/common/static/coffee/src/discussion/discussion_module_view.coffee @@ -7,7 +7,7 @@ if Backbone? "click .new-post-btn": "toggleNewPost" "keydown .new-post-btn": (event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost) - "click .new-post-cancel": "hideNewPost" + "click .cancel": "hideNewPost" "click .discussion-paginator a": "navigateToPage" paginationTemplate: -> DiscussionUtil.getTemplate("_pagination") @@ -101,7 +101,7 @@ if Backbone? @newPostForm = $('.new-post-article') @threadviews = @discussion.map (thread) -> - new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread + new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline" _.each @threadviews, (dtv) -> dtv.render() DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info) @newPostView = new NewPostView( @@ -124,7 +124,7 @@ if Backbone? # TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1? article = $("
    ") @$('section.discussion > .threads').prepend(article) - threadView = new DiscussionThreadInlineView el: article, model: thread + threadView = new DiscussionThreadView el: article, model: thread, mode: "inline" threadView.render() @threadviews.unshift threadView diff --git a/common/static/coffee/src/discussion/discussion_router.coffee b/common/static/coffee/src/discussion/discussion_router.coffee index e61a40e6a1..549c0ec830 100644 --- a/common/static/coffee/src/discussion/discussion_router.coffee +++ b/common/static/coffee/src/discussion/discussion_router.coffee @@ -25,7 +25,7 @@ if Backbone? @newPostView.render() $('.new-post-btn').bind "click", @showNewPost $('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost) - $('.new-post-cancel').bind "click", @hideNewPost + @newPostView.$('.cancel').bind "click", @hideNewPost allThreads: -> @nav.updateSidebar() @@ -45,8 +45,12 @@ if Backbone? if(@main) @main.cleanup() @main.undelegateEvents() + unless($(".forum-content").is(":visible")) + $(".forum-content").fadeIn() + if(@newPost.is(":visible")) + @newPost.fadeOut() - @main = new DiscussionThreadView(el: $(".discussion-column"), model: @thread) + @main = new DiscussionThreadView(el: $(".forum-content"), model: @thread, mode: "tab") @main.render() @main.on "thread:responses:rendered", => @nav.updateSidebar() @@ -59,8 +63,17 @@ if Backbone? @navigate("", trigger: true) showNewPost: (event) => - @newPost.slideDown(300) - $('.new-post-title').focus() + $('.forum-content').fadeOut( + duration: 200 + complete: => + @newPost.fadeIn(200) + $('.new-post-title').focus() + ) hideNewPost: (event) => - @newPost.slideUp(300) + @newPost.fadeOut( + duration: 200 + complete: => + $('.forum-content').fadeIn(200) + ) + diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 50530622b2..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 @@ -41,6 +40,9 @@ class @DiscussionUtil ta = _.union(@roleIds['Community TA']) _.include(ta, parseInt(user_id)) + @isPrivilegedUser: (user_id) -> + @isStaff(user_id) || @isTA(user_id) + @bulkUpdateContentInfo: (infos) -> for id, info of infos Content.getContent(id).updateInfo(info) @@ -159,6 +161,13 @@ class @DiscussionUtil params["$loading"].loaded() return request + @updateWithUndo: (model, updates, safeAjaxParams, errorMsg) -> + if errorMsg + safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg) + undo = _.pick(model.attributes, _.keys(updates)) + model.set(updates) + @safeAjax(safeAjaxParams).fail(() -> model.set(undo)) + @bindLocalEvents: ($local, eventsHandler) -> for eventSelector, handler of eventsHandler [event, selector] = eventSelector.split(' ') @@ -167,7 +176,7 @@ class @DiscussionUtil @formErrorHandler: (errorsField) -> (xhr, textStatus, error) -> makeErrorElem = (message) -> - $("
  1. ").addClass("new-post-form-error").html(message) + $("
  2. ").addClass("post-error").html(message) errorsField.empty().show() if xhr.status == 400 response = JSON.parse(xhr.responseText) 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 be36da1d43..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,40 +8,6 @@ if Backbone? (event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse) attrRenderer: - endorsed: (endorsed) -> - if endorsed - @$(".action-endorse").show().addClass("is-endorsed") - else - if @model.get('ability')?.can_endorse - @$(".action-endorse").show() - else - @$(".action-endorse").hide() - @$(".action-endorse").removeClass("is-endorsed") - - closed: (closed) -> - return if not @$(".action-openclose").length - return if not @$(".post-status-closed").length - if closed - @$(".post-status-closed").show() - @$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Close"), gettext("Open"))) - @$(".discussion-reply-new").hide() - else - @$(".post-status-closed").hide() - @$(".action-openclose").html(@$(".action-openclose").html().replace(gettext("Open"), gettext("Close"))) - @$(".discussion-reply-new").show() - - voted: (voted) -> - - votes_point: (votes_point) -> - - comments_count: (comments_count) -> - - subscribed: (subscribed) -> - if subscribed - @$(".dogear").addClass("is-followed").attr("aria-checked", "true") - else - @$(".dogear").removeClass("is-followed").attr("aria-checked", "false") - ability: (ability) -> for action, selector of @abilityRenderer if not ability[action] @@ -51,23 +17,22 @@ if Backbone? abilityRenderer: editable: - enable: -> @$(".action-edit").closest("li").show() - disable: -> @$(".action-edit").closest("li").hide() + enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden") + disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden") can_delete: - enable: -> @$(".action-delete").closest("li").show() - disable: -> @$(".action-delete").closest("li").hide() - can_endorse: - enable: -> - @$(".action-endorse").show().css("cursor", "auto") - disable: -> - @$(".action-endorse").css("cursor", "default") - if not @model.get('endorsed') - @$(".action-endorse").hide() - else - @$(".action-endorse").show() + 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() @@ -79,15 +44,6 @@ if Backbone? if @attrRenderer[attr] @attrRenderer[attr].apply(@, [value]) - $: (selector) -> - @$local.find(selector) - - initLocal: -> - @$local = @$el.children(".local") - if not @$local.length - @$local = @$el - @$delegateElement = @$local - makeWmdEditor: (cls_identifier) => if not @$el.find(".wmd-panel").length DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier @@ -103,116 +59,238 @@ if Backbone? initialize: -> - @initLocal() @model.bind('change', @renderPartialAttrs, @) - - - 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_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 23a090c8f5..d1e2ad6136 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -10,6 +10,7 @@ if Backbone? "change .forum-nav-sort-control": "sortThreads" "click .forum-nav-thread-link": "threadSelected" "click .forum-nav-load-more-link": "loadMorePages" + "change .forum-nav-filter-main-control": "chooseFilter" "change .forum-nav-filter-cohort-control": "chooseCohort" initialize: -> @@ -75,7 +76,7 @@ if Backbone? #TODO fix this entire chain of events addAndSelectThread: (thread) => commentable_id = thread.get("commentable_id") - menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id").id == commentable_id) + menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id) @setCurrentTopicDisplay(@getPathText(menuItem)) @retrieveDiscussion commentable_id, => @trigger "thread:created", thread.get('id') @@ -173,7 +174,7 @@ if Backbone? loadingElem = loadMoreElem.find(".forum-nav-loading") DiscussionUtil.makeFocusTrap(loadingElem) loadingElem.focus() - options = {} + options = {filter: @filter} switch @mode when 'search' options.search_text = @current_search @@ -242,7 +243,7 @@ if Backbone? goHome: -> @template = _.template($("#discussion-home").html()) - $(".discussion-column").html(@template) + $(".forum-content").html(@template) $(".forum-nav-thread-list a").removeClass("is-active") $("input.email-setting").bind "click", @updateEmailNotifications url = DiscussionUtil.urlFor("notifications_status",window.user.get("id")) @@ -363,26 +364,24 @@ if Backbone? @discussionIds = "" @$('.forum-nav-filter-cohort').show() @retrieveAllThreads() - else if item.hasClass("forum-nav-browse-menu-flagged") - @discussionIds = "" - @$('.forum-nav-filter-cohort').hide() - @retrieveFlaggedThreads() else if item.hasClass("forum-nav-browse-menu-following") @retrieveFollowed() @$('.forum-nav-filter-cohort').hide() else allItems = item.find(".forum-nav-browse-menu-item").andSelf() discussionIds = allItems.filter("[data-discussion-id]").map( - (i, elem) -> $(elem).data("discussion-id").id + (i, elem) -> $(elem).data("discussion-id") ).get() @retrieveDiscussions(discussionIds) @$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true) - chooseCohort: (event) -> + chooseFilter: (event) => + @filter = $(".forum-nav-filter-main-control :selected").val() + @retrieveFirstPage() + + chooseCohort: (event) => @group_id = @$('.forum-nav-filter-cohort-control :selected').val() - @collection.current_page = 0 - @collection.reset() - @loadMorePages(event) + @retrieveFirstPage() retrieveDiscussion: (discussion_id, callback=null) -> url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id) @@ -413,12 +412,6 @@ if Backbone? @collection.reset() @loadMorePages(event) - retrieveFlaggedThreads: (event)-> - @collection.current_page = 0 - @collection.reset() - @mode = 'flagged' - @loadMorePages(event) - sortThreads: (event) -> @displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val()) @@ -434,6 +427,7 @@ if Backbone? searchFor: (text) -> @clearSearchAlerts() + @clearFilters() @mode = 'search' @current_search = text url = DiscussionUtil.urlFor("search") @@ -499,6 +493,11 @@ if Backbone? clearSearch: -> @$(".forum-nav-search-input").val("") @current_search = "" + @clearSearchAlerts() + + clearFilters: -> + @$(".forum-nav-filter-main-control").val("all") + @$(".forum-nav-filter-cohort-control").val("all") retrieveFollowed: () => @mode = 'followed' 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 9be9de7b72..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,42 +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) - - initialize: -> + class @DiscussionThreadShowView extends DiscussionContentShowView + initialize: (options) -> super() - @model.on "change", @updateModelDetails + @mode = options.mode or "inline" # allowed values are "tab" or "inline" + if @mode not in ["tab", "inline"] + throw new Error("invalid mode: " + @mode) renderTemplate: -> @template = _.template($("#thread-show-template").html()) - @template(@model.toJSON()) + context = $.extend( + { + mode: @mode, + flagged: @model.isFlagged(), + author_display: @getAuthorDisplay(), + cid: @model.cid + }, + @model.attributes, + ) + @template(context) render: -> @$el.html(@renderTemplate()) @delegateEvents() - @renderVote() - @renderFlagged() - @renderPinned() @renderAttrs() @$("span.timeago").timeago() @convertMath() @@ -44,60 +29,6 @@ if Backbone? @highlight @$("h1,h3") @ - renderFlagged: => - if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @$("[data-role=thread-flag]").addClass("flagged") - @$("[data-role=thread-flag]").removeClass("notflagged") - @$(".discussion-flag-abuse").attr("aria-pressed", "true") - @$(".discussion-flag-abuse").attr("data-tooltip", gettext("Click to remove report")) - ### - Translators: The text between start_sr_span and end_span is not shown - in most browsers but will be read by screen readers. - ### - @$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "", "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() @@ -109,74 +40,6 @@ if Backbone? _delete: (event) -> @trigger "thread:_delete", event - togglePin: (event) => - event.preventDefault() - if @model.get('pinned') - @unPin() - else - @pin() - - pin: => - url = @model.urlFor("pinThread") - DiscussionUtil.safeAjax - $elem: @$(".discussion-pin") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set('pinned', true) - error: => - DiscussionUtil.discussionAlert("Sorry", "We had some trouble pinning this thread. Please try again.") - - unPin: => - url = @model.urlFor("unPinThread") - DiscussionUtil.safeAjax - $elem: @$(".discussion-pin") - url: url - type: "POST" - success: (response, textStatus) => - if textStatus == 'success' - @model.set('pinned', false) - error: => - DiscussionUtil.discussionAlert("Sorry", "We had some trouble unpinning this thread. Please try again.") - - toggleClosed: (event) -> - $elem = $(event.target) - url = @model.urlFor('close') - closed = @model.get('closed') - data = { closed: not closed } - DiscussionUtil.safeAjax - $elem: $elem - url: url - data: data - type: "POST" - success: (response, textStatus) => - @model.set('closed', not closed) - @model.set('ability', response.ability) - - toggleEndorse: (event) -> - $elem = $(event.target) - url = @model.urlFor('endorse') - endorsed = @model.get('endorsed') - data = { endorsed: not endorsed } - DiscussionUtil.safeAjax - $elem: $elem - url: url - data: data - type: "POST" - success: (response, textStatus) => - @model.set('endorsed', not endorsed) - highlight: (el) -> if el.html() el.html(el.html().replace(/<mark>/g, "").replace(/<\/mark>/g, "")) - - class @DiscussionThreadInlineShowView extends DiscussionThreadShowView - renderTemplate: -> - @template = DiscussionUtil.getTemplate('_inline_thread_show') - params = @model.toJSON() - if @model.get('username')? - params = $.extend(params, user:{username: @model.username, user_url: @model.user_url}) - Mustache.render(@template, params) - - 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 9f5a1a4ec8..5d312051ed 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee @@ -7,14 +7,25 @@ if Backbone? events: "click .discussion-submit-post": "submitComment" "click .add-response-btn": "scrollToAddResponse" + "click .forum-thread-expand": "expand" + "click .forum-thread-collapse": "collapse" $: (selector) -> @$el.find(selector) - initialize: -> + isQuestion: -> + @model.get("thread_type") == "question" + + initialize: (options) -> super() + @mode = options.mode or "inline" # allowed values are "tab" or "inline" + if @mode not in ["tab", "inline"] + throw new Error("invalid mode: " + @mode) @createShowView() @responses = new Comments() + @loadedResponses = false + if @isQuestion() + @markedAnswers = new Comments() renderTemplate: -> @template = _.template($("#thread-template").html()) @@ -22,7 +33,6 @@ if Backbone? render: -> @$el.html(@renderTemplate()) - @initLocal() @delegateEvents() @renderShowView() @@ -31,11 +41,53 @@ if Backbone? @$("span.timeago").timeago() @makeWmdEditor "reply-body" @renderAddResponseButton() - @responses.on("add", @renderResponse) - # Without a delay, jQuery doesn't add the loading extension defined in - # utils.coffee before safeAjax is invoked, which results in an error - setTimeout((=> @loadInitialResponses()), 100) - @ + @responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {})) + if @isQuestion() + @markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true})) + if @mode == "tab" + # Without a delay, jQuery doesn't add the loading extension defined in + # utils.coffee before safeAjax is invoked, which results in an error + setTimeout((=> @loadInitialResponses()), 100) + @$(".post-tools").hide() + else # mode == "inline" + @collapse() + + attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, { + closed: (closed) -> + @$(".discussion-reply-new").toggle(not closed) + @renderAddResponseButton() + }) + + expand: (event) -> + if event + event.preventDefault() + @$el.addClass("expanded") + @$el.find(".post-body").html(@model.get("body")) + @showView.convertMath() + @$el.find(".forum-thread-expand").hide() + @$el.find(".forum-thread-collapse").show() + @$el.find(".post-extended-content").show() + if not @loadedResponses + @loadInitialResponses() + + collapse: (event) -> + if event + event.preventDefault() + @$el.removeClass("expanded") + @$el.find(".post-body").html(@getAbbreviatedBody()) + @showView.convertMath() + @$el.find(".forum-thread-expand").show() + @$el.find(".forum-thread-collapse").hide() + @$el.find(".post-extended-content").hide() + + getAbbreviatedBody: -> + cached = @model.get("abbreviatedBody") + if cached + cached + else + abbreviated = DiscussionUtil.abbreviateString @model.get("body"), 140 + @model.set("abbreviatedBody", abbreviated) + abbreviated cleanup: -> if @responsesRequest? @@ -54,9 +106,20 @@ if Backbone? @responseRequest = null success: (data, textStatus, xhr) => Content.loadContentInfos(data['annotated_content_info']) - @responses.add(data['content']['children']) - @renderResponseCountAndPagination(data['content']['resp_total']) + if @isQuestion() + @markedAnswers.add(data["content"]["endorsed_responses"]) + @responses.add( + if @isQuestion() + then data["content"]["non_endorsed_responses"] + else data["content"]["children"] + ) + @renderResponseCountAndPagination( + if @isQuestion() + then data["content"]["non_endorsed_resp_total"] + else data["content"]["resp_total"] + ) @trigger "thread:responses:rendered" + @loadedResponses = true error: (xhr) => if xhr.status == 404 DiscussionUtil.discussionAlert( @@ -75,16 +138,24 @@ if Backbone? ) loadInitialResponses: () -> - @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true) + @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true) renderResponseCountAndPagination: (responseTotal) => + if @isQuestion() && @markedAnswers.length != 0 + responseCountFormat = ngettext( + "%(numResponses)s other response", + "%(numResponses)s other responses", + responseTotal + ) + else + responseCountFormat = ngettext( + "%(numResponses)s response", + "%(numResponses)s responses", + responseTotal + ) @$el.find(".response-count").html( interpolate( - ngettext( - "%(numResponses)s response", - "%(numResponses)s responses", - responseTotal - ), + responseCountFormat, {numResponses: responseTotal}, true ) @@ -126,17 +197,17 @@ if Backbone? loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton)) responsePagination.append(loadMoreButton) - renderResponse: (response) => + renderResponseToList: (response, listSelector, options) => response.set('thread', @model) - view = new ThreadResponseView(model: response) + view = new ThreadResponseView($.extend({model: response}, options)) view.on "comment:add", @addComment view.on "comment:endorse", @endorseThread view.render() - @$el.find(".responses").append(view.el) + @$el.find(listSelector).append(view.el) view.afterInsert() - renderAddResponseButton: -> - if @model.hasResponses() and @model.can('can_reply') + renderAddResponseButton: => + if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed') @$el.find('div.add-response').show() else @$el.find('div.add-response').hide() @@ -150,9 +221,8 @@ if Backbone? addComment: => @model.comment() - endorseThread: (endorsed) => - is_endorsed = @$el.find(".is-endorsed").length - @model.set 'endorsed', is_endorsed + endorseThread: => + @model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0 submitComment: (event) -> event.preventDefault() @@ -162,7 +232,7 @@ if Backbone? @setWmdContent("reply-body", "") comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id")) comment.set('thread', @model.get('thread')) - @renderResponse(comment) + @renderResponseToList(comment, ".js-response-list") @model.addComment() @renderAddResponseButton() @@ -209,6 +279,7 @@ if Backbone? @model.set title: newTitle body: newBody + @model.unset("abbreviatedBody") @createShowView() @renderShowView() @@ -232,9 +303,6 @@ if Backbone? renderEditView: () -> @renderSubView(@editView) - getShowViewClass: () -> - return DiscussionThreadShowView - createShowView: () -> if @editView? @@ -242,8 +310,7 @@ if Backbone? @editView.$el.empty() @editView = null - showViewClass = @getShowViewClass() - @showView = new showViewClass(model: @model) + @showView = new DiscussionThreadShowView({model: @model, mode: @mode}) @showView.bind "thread:_delete", @_delete @showView.bind "thread:edit", @edit diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view_inline.coffee b/common/static/coffee/src/discussion/views/discussion_thread_view_inline.coffee deleted file mode 100644 index b28c4bba8d..0000000000 --- a/common/static/coffee/src/discussion/views/discussion_thread_view_inline.coffee +++ /dev/null @@ -1,71 +0,0 @@ -if Backbone? - class @DiscussionThreadInlineView extends DiscussionThreadView - expanded = false - events: - "click .discussion-submit-post": "submitComment" - "click .expand-post": "expandPost" - "click .collapse-post": "collapsePost" - "click .add-response-btn": "scrollToAddResponse" - - initialize: -> - super() - - initLocal: -> - @$local = @$el.children(".discussion-article").children(".local") - if not @$local.length - @$local = @$el - @$delegateElement = @$local - - renderTemplate: () -> - if @model.has('group_id') - @template = DiscussionUtil.getTemplate("_inline_thread_cohorted") - else - @template = DiscussionUtil.getTemplate("_inline_thread") - - if not @model.has('abbreviatedBody') - @abbreviateBody() - params = @model.toJSON() - Mustache.render(@template, params) - - render: () -> - super() - @$el.find('.post-extended-content').hide() - @$el.find('.collapse-post').hide() - - getShowViewClass: () -> - return DiscussionThreadInlineShowView - - loadInitialResponses: () -> - if @expanded - super() - - abbreviateBody: -> - abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140 - @model.set('abbreviatedBody', abbreviated) - - expandPost: (event) => - @$el.addClass('expanded') - @$el.find('.post-body').html(@model.get('body')) - @showView.convertMath() - @$el.find('.expand-post').css('display', 'none') - @$el.find('.collapse-post').css('display', 'block') - @$el.find('.post-extended-content').show() - if not @expanded - @expanded = true - @loadInitialResponses() - - collapsePost: (event) -> - curScroll = $(window).scrollTop() - postTop = @$el.offset().top - if postTop < curScroll - $('html, body').animate({scrollTop: postTop}) - @$el.removeClass('expanded') - @$el.find('.post-body').html(@model.get('abbreviatedBody')) - @showView.convertMath() - @$el.find('.expand-post').css('display', 'block') - @$el.find('.collapse-post').css('display', 'none') - @$el.find('.post-extended-content').hide() - - createEditView: () -> - super() - @editView.bind "thread:update", @abbreviateBody diff --git a/common/static/coffee/src/discussion/views/new_post_view.coffee b/common/static/coffee/src/discussion/views/new_post_view.coffee index cd4d959f3a..87eb067c7e 100644 --- a/common/static/coffee/src/discussion/views/new_post_view.coffee +++ b/common/static/coffee/src/discussion/views/new_post_view.coffee @@ -10,72 +10,51 @@ if Backbone? @topicId = options.topicId render: () -> + context = _.clone(@course_settings.attributes) + _.extend(context, { + cohort_options: @getCohortOptions(), + mode: @mode, + form_id: @mode + (if @topicId then "-" + @topicId else "") + }) + context.topics_html = @renderCategoryMap(@course_settings.get("category_map")) if @mode is "tab" + @$el.html(_.template($("#new-post-template").html(), context)) + if @mode is "tab" - @$el.html( - _.template( - $("#new-post-tab-template").html(), { - topic_dropdown_html: @getTopicDropdownHTML(), - options_html: @getOptionsHTML(), - editor_html: @getEditorHTML() - } - ) - ) # set up the topic dropdown in tab mode - @dropdownButton = @$(".topic_dropdown_button") - @topicMenu = @$(".topic_menu_wrapper") - @menuOpen = @dropdownButton.hasClass('dropped') - @topicId = @$(".topic").first().data("discussion_id") - @topicText = @getFullTopicName(@$(".topic").first()) - $('.choose-cohort').hide() unless @$(".topic_menu li a").first().is("[cohorted=true]") - @setSelectedTopic() - else # inline - @$el.html( - _.template( - $("#new-post-inline-template").html(), { - options_html: @getOptionsHTML(), - editor_html: @getEditorHTML() - } - ) - ) - DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body" + @dropdownButton = @$(".post-topic-button") + @topicMenu = @$(".topic-menu-wrapper") + @hideTopicDropdown() + @setTopic(@$("a.topic-title").first()) - getTopicDropdownHTML: () -> - # populate the category menu (topic dropdown) - _renderCategoryMap = (map) -> - category_template = _.template($("#new-post-menu-category-template").html()) - entry_template = _.template($("#new-post-menu-entry-template").html()) - html = "" - for name in map.children - if name of map.entries - entry = map.entries[name] - html += entry_template({text: name, id: entry.id, is_cohorted: entry.is_cohorted}) - else # subcategory - html += category_template({text: name, entries: _renderCategoryMap(map.subcategories[name])}) - html - topics_html = _renderCategoryMap(@course_settings.get("category_map")) - _.template($("#new-post-topic-dropdown-template").html(), {topics_html: topics_html}) + DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "js-post-body" - getEditorHTML: () -> - _.template($("#new-post-editor-template").html(), {}) + renderCategoryMap: (map) -> + category_template = _.template($("#new-post-menu-category-template").html()) + entry_template = _.template($("#new-post-menu-entry-template").html()) + html = "" + for name in map.children + if name of map.entries + entry = map.entries[name] + html += entry_template({text: name, id: entry.id, is_cohorted: entry.is_cohorted}) + else # subcategory + html += category_template({text: name, entries: @renderCategoryMap(map.subcategories[name])}) + html - getOptionsHTML: () -> - # cohort options? + getCohortOptions: () -> if @course_settings.get("is_cohorted") and DiscussionUtil.isStaff() user_cohort_id = $("#discussion-container").data("user-cohort-id") - cohort_options = _.map @course_settings.get("cohorts"), (cohort) -> + _.map @course_settings.get("cohorts"), (cohort) -> {value: cohort.id, text: cohort.name, selected: cohort.id==user_cohort_id} else - cohort_options = null - context = _.clone(@course_settings.attributes) - context.cohort_options = cohort_options - _.template($("#new-post-options-template").html(), context) + null events: - "submit .new-post-form": "createPost" - "click .topic_dropdown_button": "toggleTopicDropdown" - "click .topic_menu_wrapper": "setTopic" - "click .topic_menu_search": "ignoreClick" - "keyup .form-topic-drop-search-input": DiscussionFilter.filterDrop + "submit .forum-new-post-form": "createPost" + "click .post-topic-button": "toggleTopicDropdown" + "click .topic-menu-wrapper": "handleTopicEvent" + "click .topic-filter-label": "ignoreClick" + "keyup .topic-filter-input": DiscussionFilter.filterDrop + "change .post-option-input": "postOptionChange" # Because we want the behavior that when the body is clicked the menu is # closed, we need to ignore clicks in the search field and stop propagation. @@ -83,15 +62,24 @@ if Backbone? ignoreClick: (event) -> event.stopPropagation() + postOptionChange: (event) -> + $target = $(event.target) + $optionElem = $target.closest(".post-option") + if $target.is(":checked") + $optionElem.addClass("is-enabled") + else + $optionElem.removeClass("is-enabled") + createPost: (event) -> event.preventDefault() - title = @$(".new-post-title").val() - body = @$(".new-post-body").find(".wmd-input").val() - group = @$(".new-post-group option:selected").attr("value") + thread_type = @$(".post-type-input:checked").val() + title = @$(".js-post-title").val() + body = @$(".js-post-body").find(".wmd-input").val() + group = @$(".js-group-select option:selected").attr("value") - anonymous = false || @$("input.discussion-anonymous").is(":checked") - anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked") - follow = false || @$("input.discussion-follow").is(":checked") + anonymous = false || @$(".js-anon").is(":checked") + anonymous_to_peers = false || @$(".js-anon-peers").is(":checked") + follow = false || @$(".js-follow").is(":checked") url = DiscussionUtil.urlFor('create_thread', @topicId) @@ -103,24 +91,27 @@ if Backbone? dataType: 'json' async: false # TODO when the rest of the stuff below is made to work properly.. data: + thread_type: thread_type title: title body: body anonymous: anonymous anonymous_to_peers: anonymous_to_peers auto_subscribe: follow group_id: group - error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors")) + error: DiscussionUtil.formErrorHandler(@$(".post-errors")) success: (response, textStatus) => # TODO: Move this out of the callback, this makes it feel sluggish thread = new Thread response['content'] - DiscussionUtil.clearFormErrors(@$(".new-post-form-errors")) + DiscussionUtil.clearFormErrors(@$(".post-errors")) @$el.hide() - @$(".new-post-title").val("").attr("prev-text", "") - @$(".new-post-body textarea").val("").attr("prev-text", "") + @$(".js-post-title").val("").attr("prev-text", "") + @$(".js-post-body textarea").val("").attr("prev-text", "") @$(".wmd-preview p").html("") # only line not duplicated in new post inline view @collection.add thread + toggleTopicDropdown: (event) -> + event.preventDefault() event.stopPropagation() if @menuOpen @hideTopicDropdown() @@ -133,7 +124,6 @@ if Backbone? @topicMenu.show() $(".form-topic-drop-search-input").focus() - $("body").bind "keydown", @setActiveItem $("body").bind "click", @hideTopicDropdown # Set here because 1) the window might get resized and things could @@ -146,28 +136,33 @@ if Backbone? @dropdownButton.removeClass('dropped') @topicMenu.hide() - $("body").unbind "keydown", @setActiveItem $("body").unbind "click", @hideTopicDropdown - setTopic: (event) -> - $target = $(event.target) - if $target.data('discussion_id') + handleTopicEvent: (event) -> + event.preventDefault() + event.stopPropagation() + @setTopic($(event.target)) + + setTopic: ($target) -> + if $target.data('discussion-id') @topicText = $target.html() @topicText = @getFullTopicName($target) - @topicId = $target.data('discussion_id') + @topicId = $target.data('discussion-id') @setSelectedTopic() - if $target.is('[cohorted=true]') - $('.choose-cohort').show(); + if $target.data("cohorted") + $(".js-group-select").prop("disabled", false) else - $('.choose-cohort').hide(); + $(".js-group-select").val("") + $(".js-group-select").prop("disabled", true) + @hideTopicDropdown() setSelectedTopic: -> - @dropdownButton.html(@fitName(@topicText) + ' ') + @$(".js-selected-topic").html(@fitName(@topicText)) getFullTopicName: (topicElement) -> name = topicElement.html() - topicElement.parents('ul').not('.topic_menu').each -> - name = $(this).siblings('a').text() + ' / ' + name + topicElement.parents('.topic-submenu').each -> + name = $(this).siblings('.topic-title').text() + ' / ' + name return name getNameWidth: (name) -> @@ -204,29 +199,3 @@ if Backbone? name = gettext("…") + " / " + rawName + " " + gettext("…") return name - - setActiveItem: (event) -> - if event.which == 13 - $(".topic_menu_wrapper .focused").click() - return - if event.which != 40 && event.which != 38 - return - event.preventDefault() - - items = $.makeArray($(".topic_menu_wrapper a").not(".hidden")) - index = items.indexOf($('.topic_menu_wrapper .focused')[0]) - - if event.which == 40 - index = Math.min(index + 1, items.length - 1) - if event.which == 38 - index = Math.max(index - 1, 0) - - $(".topic_menu_wrapper .focused").removeClass("focused") - $(items[index]).addClass("focused") - - itemTop = $(items[index]).parent().offset().top - scrollTop = $(".topic_menu").scrollTop() - itemFromTop = $(".topic_menu").offset().top - itemTop - scrollTarget = Math.min(scrollTop - itemFromTop, scrollTop) - scrollTarget = Math.max(scrollTop - itemFromTop - $(".topic_menu").height() + $(items[index]).height() + 20, scrollTarget) - $(".topic_menu").scrollTop(scrollTarget) 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 3ca9570300..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,40 +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)) - @initLocal() @delegateEvents() @renderAttrs() - @renderFlagged() - @markAsStaff() @$el.find(".timeago").timeago() @convertMath() @addReplyLink() @@ -52,31 +35,8 @@ if Backbone? body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text() MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]] - markAsStaff: -> - if DiscussionUtil.isStaff(@model.get("user_id")) - @$el.find("a.profile-link").after('' + 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 312b660ff6..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,37 +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) - - $: (selector) -> - @$el.find(selector) - + class @ThreadResponseShowView extends DiscussionContentShowView initialize: -> super() - @model.on "change", @updateModelDetails + @listenTo(@model, "change", @render) renderTemplate: -> @template = _.template($("#thread-response-show-template").html()) - @template(@model.toJSON()) + context = _.extend( + { + cid: @model.cid, + author_display: @getAuthorDisplay(), + endorser_display: @getEndorserDisplay() + }, + @model.attributes + ) + @template(context) render: -> @$el.html(@renderTemplate()) @delegateEvents() - @renderVote() @renderAttrs() - @renderFlagged() - @$el.find(".posted-details").timeago() + @$el.find(".posted-details .timeago").timeago() @convertMath() - @markAsStaff() @ convertMath: -> @@ -39,54 +29,8 @@ if Backbone? element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]] - markAsStaff: -> - if DiscussionUtil.isStaff(@model.get("user_id")) - @$el.addClass("staff") - @$el.prepend('
    ' + 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.can('can_endorse') - return - $elem = $(event.target) - url = @model.urlFor('endorse') - endorsed = @model.get('endorsed') - data = { endorsed: not endorsed } - @model.set('endorsed', not endorsed) - @trigger "comment:endorse", not endorsed - DiscussionUtil.safeAjax - $elem: $elem - url: url - data: data - type: "POST" - - - renderFlagged: => - if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @$("[data-role=thread-flag]").addClass("flagged") - @$("[data-role=thread-flag]").removeClass("notflagged") - @$(".discussion-flag-abuse").attr("aria-pressed", "true") - @$(".discussion-flag-abuse").attr("data-tooltip", gettext("Misuse Reported, click to remove report")) - ### - Translators: The text between start_sr_span and end_span is not shown - in most browsers but will be read by screen readers. - ### - @$(".discussion-flag-abuse .flag-label").html(interpolate(gettext("Misuse Reported%(start_sr_span)s, click to remove report%(end_span)s"), {"start_sr_span": "", "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")) - - updateModelDetails: => - @renderVote() - @renderFlagged() diff --git a/common/static/coffee/src/discussion/views/thread_response_view.coffee b/common/static/coffee/src/discussion/views/thread_response_view.coffee index 8dc157df9b..e6a3412c59 100644 --- a/common/static/coffee/src/discussion/views/thread_response_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_view.coffee @@ -1,6 +1,7 @@ if Backbone? class @ThreadResponseView extends DiscussionContentView tagName: "li" + className: "forum-response" events: "click .discussion-submit-comment": "submitComment" @@ -9,7 +10,8 @@ if Backbone? $: (selector) -> @$el.find(selector) - initialize: -> + initialize: (options) -> + @collapseComments = options.collapseComments @createShowView() renderTemplate: -> @@ -65,6 +67,15 @@ if Backbone? collectComments(child) @model.get('comments').each collectComments comments.each (comment) => @renderComment(comment, false, null) + if @collapseComments && comments.length + @$(".comments").hide() + @$(".action-show-comments").on("click", (event) => + event.preventDefault() + @$(".action-show-comments").hide() + @$(".comments").show() + ) + else + @$(".action-show-comments").hide() renderComment: (comment) => comment.set('thread', @model.get('thread')) @@ -155,6 +166,7 @@ if Backbone? @showView = new ThreadResponseShowView(model: @model) @showView.bind "response:_delete", @_delete @showView.bind "response:edit", @edit + @showView.on "comment:endorse", => @trigger("comment:endorse") renderShowView: () -> @renderSubView(@showView) diff --git a/common/static/js/src/utility.js b/common/static/js/src/utility.js index ef540373fc..2ea5da08fc 100644 --- a/common/static/js/src/utility.js +++ b/common/static/js/src/utility.js @@ -91,75 +91,7 @@ window.parseQueryString = function(queryString) { return parameters }; -// Check if the user recently enrolled in a course by looking at a referral URL -window.checkRecentEnrollment = function(referrer) { - var enrolledIn = null; - - // Check if the referrer URL contains a query string - if (referrer.indexOf("?") > -1) { - referrerQueryString = referrer.split("?")[1]; - } else { - referrerQueryString = ""; - } - - if (referrerQueryString != "") { - // Convert a non-empty query string into a key/value object - var referrerParameters = window.parseQueryString(referrerQueryString); - if ("course_id" in referrerParameters && "enrollment_action" in referrerParameters) { - if (referrerParameters.enrollment_action == "enroll") { - enrolledIn = referrerParameters.course_id; - } - } - } - - return enrolledIn -}; - -window.assessUserSignIn = function(parameters, userID, email, username) { - // Check if the user has logged in to enroll in a course - designed for when "Register" button registers users on click (currently, this could indicate a course registration when there may not have yet been one) - var enrolledIn = window.checkRecentEnrollment(document.referrer); - - // Check if the user has just registered - if (parameters.signin == "initial") { - window.trackAccountRegistration(enrolledIn, userID, email, username); - } else { - window.trackReturningUserSignIn(enrolledIn, userID, email, username); - } -}; - -window.trackAccountRegistration = function(enrolledIn, userID, email, username) { - // Alias the user's anonymous history with the user's new identity (for Mixpanel) - analytics.alias(userID); - - // Map the user's activity to their newly assigned ID - analytics.identify(userID, { - email: email, - username: username - }); - - // Track the user's account creation - analytics.track("edx.bi.user.account.registered", { - category: "conversion", - label: enrolledIn != null ? enrolledIn : "none" - }); -}; - -window.trackReturningUserSignIn = function(enrolledIn, userID, email, username) { - // Map the user's activity to their assigned ID - analytics.identify(userID, { - email: email, - username: username - }); - - // Track the user's sign in - analytics.track("edx.bi.user.account.authenticated", { - category: "conversion", - label: enrolledIn != null ? enrolledIn : "none" - }); -}; - window.identifyUser = function(userID, email, username) { - // If the signin parameter isn't present but the query string is non-empty, map the user's activity to their assigned ID analytics.identify(userID, { email: email, username: username diff --git a/common/test/acceptance/fixtures/discussion.py b/common/test/acceptance/fixtures/discussion.py index fad8ac5686..a521a36c8e 100644 --- a/common/test/acceptance/fixtures/discussion.py +++ b/common/test/acceptance/fixtures/discussion.py @@ -30,6 +30,7 @@ class ContentFactory(factory.Factory): class Thread(ContentFactory): + thread_type = "discussion" anonymous = False anonymous_to_peers = False comments_count = 0 @@ -87,7 +88,13 @@ class SingleThreadViewFixture(DiscussionContentFixture): def addResponse(self, response, comments=[]): response['children'] = comments - self.thread.setdefault('children', []).append(response) + if self.thread["thread_type"] == "discussion": + responseListAttr = "children" + elif response["endorsed"]: + responseListAttr = "endorsed_responses" + else: + responseListAttr = "non_endorsed_responses" + self.thread.setdefault(responseListAttr, []).append(response) self.thread['comments_count'] += len(comments) + 1 def _get_comment_map(self): diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index 23ae60fe40..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,10 +110,23 @@ 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() + with self._secondary_action_menu_open(".response_{} .discussion-response".format(response_id)): + self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click() + EmptyPromise( + lambda: self.is_response_editor_visible(response_id), + "Response edit started" + ).fulfill() + + def is_show_comments_visible(self, response_id): + """Returns true if the "show comments" link is visible for a response""" + return self._is_element_visible(".response_{} .action-show-comments".format(response_id)) + + def show_comments(self, response_id): + """Click the "show comments" link for a response""" + self._find_within(".response_{} .action-show-comments".format(response_id)).first.click() EmptyPromise( - lambda: self.is_response_editor_visible(response_id), - "Response edit started" + lambda: self._is_element_visible(".response_{} .comments".format(response_id)), + "Comments shown" ).fulfill() def is_add_comment_visible(self, response_id): @@ -108,11 +142,13 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): def is_comment_deletable(self, comment_id): """Returns true if the delete comment button is present, false otherwise""" - return self._is_element_visible("#comment_{} div.action-delete".format(comment_id)) + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + return self._is_element_visible("#comment_{} .action-delete".format(comment_id)) def delete_comment(self, comment_id): with self.handle_alert(): - self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click() + with self._secondary_action_menu_open("#comment_{}".format(comment_id)): + self._find_within("#comment_{} .action-delete".format(comment_id)).first.click() EmptyPromise( lambda: not self.is_comment_visible(comment_id), "Deleted comment was removed" @@ -120,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""" @@ -132,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""" @@ -269,7 +307,7 @@ class InlineDiscussionThreadPage(DiscussionThreadPage): def expand(self): """Clicks the link to expand the thread""" - self._find_within(".expand-post").first.click() + self._find_within(".forum-thread-expand").first.click() EmptyPromise( lambda: bool(self.get_response_total_text()), "Thread expanded" diff --git a/common/test/acceptance/tests/test_discussion.py b/common/test/acceptance/tests/test_discussion.py index 3e4a36a1c5..8c5fe9558a 100644 --- a/common/test/acceptance/tests/test_discussion.py +++ b/common/test/acceptance/tests/test_discussion.py @@ -144,6 +144,27 @@ class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginati self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201 self.thread_page.visit() + def test_marked_answer_comments(self): + thread_id = "test_thread_{}".format(uuid4().hex) + response_id = "test_response_{}".format(uuid4().hex) + comment_id = "test_comment_{}".format(uuid4().hex) + thread_fixture = SingleThreadViewFixture( + Thread(id=thread_id, commentable_id=self.discussion_id, thread_type="question") + ) + thread_fixture.addResponse( + Response(id=response_id, endorsed=True), + [Comment(id=comment_id)] + ) + thread_fixture.push() + self.setup_thread_page(thread_id) + self.assertFalse(self.thread_page.is_comment_visible(comment_id)) + self.assertFalse(self.thread_page.is_add_comment_visible(response_id)) + self.assertTrue(self.thread_page.is_show_comments_visible(response_id)) + self.thread_page.show_comments(response_id) + self.assertTrue(self.thread_page.is_comment_visible(comment_id)) + self.assertTrue(self.thread_page.is_add_comment_visible(response_id)) + self.assertFalse(self.thread_page.is_show_comments_visible(response_id)) + @attr('shard_1') class DiscussionCommentDeletionTest(UniqueCourseTest): diff --git a/docs/en_us/course_authors/source/Images/DiscussionComponent_Forum.png b/docs/en_us/course_authors/source/Images/DiscussionComponent_Forum.png deleted file mode 100644 index c5c75b8b11..0000000000 Binary files a/docs/en_us/course_authors/source/Images/DiscussionComponent_Forum.png and /dev/null differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_More_menu.png b/docs/en_us/course_authors/source/Images/Discussion_More_menu.png new file mode 100644 index 0000000000..297c72bd56 Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_More_menu.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_add_response.png b/docs/en_us/course_authors/source/Images/Discussion_add_response.png new file mode 100644 index 0000000000..c2929defcd Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_add_response.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_answer_question.png b/docs/en_us/course_authors/source/Images/Discussion_answer_question.png new file mode 100644 index 0000000000..f9a3256f4c Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_answer_question.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_answers_in_list.png b/docs/en_us/course_authors/source/Images/Discussion_answers_in_list.png new file mode 100644 index 0000000000..9d8819bf62 Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_answers_in_list.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_colorcoding.png b/docs/en_us/course_authors/source/Images/Discussion_colorcoding.png index c6c0954c31..04ef536214 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_colorcoding.png and b/docs/en_us/course_authors/source/Images/Discussion_colorcoding.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_course_wide_post.png b/docs/en_us/course_authors/source/Images/Discussion_course_wide_post.png index 0b444915bd..2595ed885a 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_course_wide_post.png and b/docs/en_us/course_authors/source/Images/Discussion_course_wide_post.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_expand.png b/docs/en_us/course_authors/source/Images/Discussion_expand.png new file mode 100644 index 0000000000..912bfdb3cd Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_expand.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_filterfollowing.png b/docs/en_us/course_authors/source/Images/Discussion_filterfollowing.png index aa9775850e..9e8056ccb7 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_filterfollowing.png and b/docs/en_us/course_authors/source/Images/Discussion_filterfollowing.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_filters.png b/docs/en_us/course_authors/source/Images/Discussion_filters.png new file mode 100644 index 0000000000..7a850f0671 Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_filters.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_follow.png b/docs/en_us/course_authors/source/Images/Discussion_follow.png index ae953e9a36..f6448c58fe 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_follow.png and b/docs/en_us/course_authors/source/Images/Discussion_follow.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_mouseover.png b/docs/en_us/course_authors/source/Images/Discussion_mouseover.png new file mode 100644 index 0000000000..05e6558f37 Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_mouseover.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_options_mouseover.png b/docs/en_us/course_authors/source/Images/Discussion_options_mouseover.png new file mode 100644 index 0000000000..4a8cdaaafb Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Discussion_options_mouseover.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_reportmisuse.png b/docs/en_us/course_authors/source/Images/Discussion_reportmisuse.png index 6a353a9f38..8d8e09ee47 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_reportmisuse.png and b/docs/en_us/course_authors/source/Images/Discussion_reportmisuse.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_sortvotes.png b/docs/en_us/course_authors/source/Images/Discussion_sortvotes.png index f798d902cd..293cf46605 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_sortvotes.png and b/docs/en_us/course_authors/source/Images/Discussion_sortvotes.png differ diff --git a/docs/en_us/course_authors/source/Images/Discussion_vote.png b/docs/en_us/course_authors/source/Images/Discussion_vote.png index 390338a37d..453960684d 100644 Binary files a/docs/en_us/course_authors/source/Images/Discussion_vote.png and b/docs/en_us/course_authors/source/Images/Discussion_vote.png differ diff --git a/docs/en_us/course_authors/source/Images/Endorse_Discussion.png b/docs/en_us/course_authors/source/Images/Endorse_Discussion.png index 5ffa90a039..88f86f3507 100644 Binary files a/docs/en_us/course_authors/source/Images/Endorse_Discussion.png and b/docs/en_us/course_authors/source/Images/Endorse_Discussion.png differ diff --git a/docs/en_us/course_authors/source/Images/NewCategory_Discussion.png b/docs/en_us/course_authors/source/Images/NewCategory_Discussion.png index d27a34fc0c..e8cdac719b 100644 Binary files a/docs/en_us/course_authors/source/Images/NewCategory_Discussion.png and b/docs/en_us/course_authors/source/Images/NewCategory_Discussion.png differ diff --git a/docs/en_us/course_authors/source/Images/Post_types_in_list.png b/docs/en_us/course_authors/source/Images/Post_types_in_list.png new file mode 100644 index 0000000000..6a68570c1d Binary files /dev/null and b/docs/en_us/course_authors/source/Images/Post_types_in_list.png differ diff --git a/docs/en_us/course_authors/source/Pin_Discussion.png b/docs/en_us/course_authors/source/Pin_Discussion.png new file mode 100644 index 0000000000..c1fb8fddd9 Binary files /dev/null and b/docs/en_us/course_authors/source/Pin_Discussion.png differ diff --git a/docs/en_us/course_authors/source/change_log.rst b/docs/en_us/course_authors/source/change_log.rst index ee4fa0ec93..29fb550f16 100644 --- a/docs/en_us/course_authors/source/change_log.rst +++ b/docs/en_us/course_authors/source/change_log.rst @@ -1,6 +1,22 @@ ############ Change Log ############ + +***************** +September, 2014 +***************** + +.. list-table:: + :widths: 10 70 + :header-rows: 1 + + * - Date + - Change + * - 09/02/14 + - Updated the :ref:`Discussions` and :ref:`Discussions for Students and + Staff` chapters to include information about choosing the type of post + and to reflect changes in the user interface. + ************** August, 2014 diff --git a/docs/en_us/course_authors/source/creating_content/create_discussion.rst b/docs/en_us/course_authors/source/creating_content/create_discussion.rst index 43a03763e3..8f6cdace03 100644 --- a/docs/en_us/course_authors/source/creating_content/create_discussion.rst +++ b/docs/en_us/course_authors/source/creating_content/create_discussion.rst @@ -51,7 +51,7 @@ Create a Discussion Component course content. The values in the **Category** and **Subcategory** fields appear in the list of discussion topics on the **Discussion** page. To uniquely identify the discussion in your course, each **Category** / - **Subcategory** pair that you supply should be unique. + **Subcategory** pair that you supply must be unique. .. image:: ../Images/Discussion_category_subcategory.png :alt: The list of discussions with the "Answering More Than Once" topic indented under "Getting Graded" @@ -102,4 +102,4 @@ In the **Discussion** tab at the top of the page, students can find the category and subcategory of the discussion in the left pane. .. image:: ../Images/Discussion_category_subcategory.png - :alt: Image of the Discussion page from a student's point of view \ No newline at end of file + :alt: Image of the Discussion page from a student's point of view diff --git a/docs/en_us/course_authors/source/getting_started/glossary.rst b/docs/en_us/course_authors/source/getting_started/glossary.rst index 8b6ff1fbcb..911bae30e4 100644 --- a/docs/en_us/course_authors/source/getting_started/glossary.rst +++ b/docs/en_us/course_authors/source/getting_started/glossary.rst @@ -161,7 +161,13 @@ D **Discussion** - The set of topics defined to promote course-wide or unit-specific dialog. Students use the discussion topics to communicate with each other and the course staff in threaded excahnges. + The set of topics defined to promote course-wide or unit-specific + conversation. Students use the discussion topics to communicate with each + other and the course staff in threaded exchanges. + + A discussion is also a type of contribution that you can make to a topic to + start an open-ended dialogue. You can also contribute questions to the + discussion topics. See :ref:`Discussions` for more information. @@ -433,6 +439,29 @@ P The page in the learning management system that shows students their scores on graded assignments in the course. + +.. _Public Unit: + +**Public Unit** + + A unit whose **Visibility** option is set to Public so that the unit is visible to students, if the subsection that contains the unit has been released. + + See :ref:`Public and Private Units` for more information. + +.. _Q: + +***** +Q +***** + +**Question** + + A question is a type of contribution that you can make to a course discussion + topic to surface an issue that the course staff or other students can + resolve. + + See :ref:`Discussions` for more information. + .. _R: **** diff --git a/docs/en_us/course_authors/source/running_course/discussions.rst b/docs/en_us/course_authors/source/running_course/discussions.rst index 94e734e9b2..f001e4c6a6 100644 --- a/docs/en_us/course_authors/source/running_course/discussions.rst +++ b/docs/en_us/course_authors/source/running_course/discussions.rst @@ -1,7 +1,7 @@ .. _Discussions: ################################## -Managing the Course Discussions +Managing Course Discussions ################################## Discussions, or discussion forums, foster interaction among your students and @@ -31,13 +31,13 @@ sections: Overview ******************************** -Students and staff use course discussions to share ideas, exchange views, and -consider different viewpoints. In a discussion, there are three hierarchical -levels of interaction. +Students and staff use course discussions to share ideas, exchange views, +consider different viewpoints, and ask questions. In a discussion, there are +three hierarchical levels of interaction. * A *post* is the first level of interaction. A post opens a new subject. Posts are often posed as questions, either to start a conversation or to surface an - issue that requires some action. + issue that requires some action. When you add a post, you categorize it as a **Question** or as a **Discussion**. * A *response* is the second level of interaction. A response is a reply made directly to a post to provide a solution or continue the conversation. @@ -46,13 +46,15 @@ levels of interaction. clarification or side note made to a specific response, rather than to the post as a whole. -The dialog created by a post, its responses, and the comments on those -responses is called a *thread*. +The dialogue created by a post, its responses, and the comments on those +responses is sometimes called a thread. All course staff members and enrolled students can add posts, responses, and comments, and view all of the posts, responses, and comments made by other -course participants. Discussion threads are saved as part of the course -history. +course participants. Members of the course community, both staff and students, +can be given permission to moderate or administer course discussions through a +set of discussion administration roles. Discussion threads are saved as part of +the course history. .. note:: The :ref:`Discussions for Students and Staff` chapter describes features that @@ -64,7 +66,7 @@ history. .. _Organizing_discussions: ************************************************* -Set Up Discussions for Your Course +Set Up Discussion Topics for Your Course ************************************************* Discussions in an edX course include both the specific topics that you add to @@ -77,11 +79,12 @@ Add Units With a Discussion Component ============================================ Typically, all units are added during the design and creation of your course in -Studio. To add a component to a unit, follow the instructions in :ref:`Working -with Discussion Components`. +Studio. To add a discussion topic to a unit, you add a discussion component. +Follow the instructions in :ref:`Working with Discussion Components`. -This type of discussion is subject to the release date of the section that -contains it. Students cannot contribute to these discussions until that date. +This type of discussion topic is subject to the release date of the section +that contains it. Students cannot contribute to these discussion topics until +that date. ===================================== Create Course-Wide Discussion Topics @@ -150,24 +153,27 @@ You can designate a team of people to help you run course discussions. Different options for working with discussions are available through these roles: -* Discussion moderators can edit and delete messages at any level, review +* *Discussion moderators* can edit and delete messages at any level, review messages flagged for misuse, close and reopen posts, pin posts, and endorse responses. Posts made by moderators are marked as "By: Staff" in the list of posts. Responses and comments made by moderators have a colored "Staff" - banner. This role is often given to course team members who already have the - Course Staff role. + identifier. This role is often given to course team members who already have + the Course Staff role. .. removed this clause from 1st sentence per JAAkana and MHoeber: , and, if the .. course is cohorted, see posts from all cohorts -* Discussion community TAs have the same options for working with discussions +* *Discussion community TAs* have the same options for working with discussions as moderators. Posts made by community TAs are marked as "By: Community TA" - in the list of posts. Responses and comments made by community TAs have a - colored "Community TA" banner. This role is often given to students. + in the list of posts on the **Discussion** page. Responses and comments made + by community TAs have a colored "Community TA" identifier. This role is often + given to students. -* Discussion admins have the same options for working with discussions as +.. put this comment in to make the formatting of this bulleted list consistent when output using the spinx template + +* *Discussion admins* have the same options for working with discussions as moderators, and their posts, responses, and comments have the same "Staff" - identifier. This role can be reserved for assignment to course team members + identifiers. This role can be reserved for assignment to course team members who have the Instructor role only: the discussion admins can then both moderate discussions and give other users these discussion management roles whenever necessary. @@ -179,7 +185,7 @@ addresses or usernames. click **Membership** and then select **Course Staff** or **Instructor** from the drop-down list. -* To get this information for any enrolled student, on the Instructor Dashboard +* To get this information for an enrolled student, on the Instructor Dashboard click **Data Download**, then **Download profile information as a CSV**. To assign a role, you must be the course author or an Instructor (that is, you @@ -206,9 +212,9 @@ Run a Discussion ********************* On an ongoing basis, the members of your discussion team run the course -discussion by making contributions, endorsing responses, and guiding student -messages into pertinent threads. Techniques that you can use throughout your -course to make discussions successful follow. +discussion by making contributions, endorsing responses, marking answers as +correct, and guiding student messages into pertinent threads. Techniques that +you can use throughout your course to make discussions successful follow. ========================================== Use Conventions in Discussion Subjects @@ -229,13 +235,21 @@ body of a response or comment. Examples follow. Both your discussion team and your students can use tags like these to search the discussions more effectively. +When a post is created its type must be selected: either "question" or +"discussion". Members of the discussion team should be thoughtful when +selecting the type for their posts, and encourage students to do the same. See +:ref:`Find Question Posts and Discussion Posts`. + +.. future: changing the type of a post, maybe resequence or separate conventions from post types + ======================== -Seed Discussions +Seed Discussion Topics ======================== -To help students learn how to get the most of course discussions, and find the -best discussion topic to use for their questions, you can seed discussions by -adding posts before your course starts. Some examples follow. +To help students learn how to get the most out of course discussions, and find +the best discussion topic to use for their questions and conversations, you can +seed discussion topics by adding posts before your course starts. Some examples +follow. * In the General topic (which is included in every course by default), add an [INTRO] post to initiate a thread for student and staff introductions. @@ -246,8 +260,8 @@ adding posts before your course starts. Some examples follow. create their own posts. * If you include discussion components along with problem components in a unit, - you can add a post that encourages students to use the discussion topic to - ask for help with the problems, but reminds them not to post the answers. + you can add a post that encourages students to use the topic to ask for help + with the problems, but reminds them not to post the answers. ====================================== Minimize Thread Proliferation @@ -259,30 +273,35 @@ long threads (with more than 200 responses and comments) can be difficult to read, and can therefore result in an unsatisfactory experience in the discussion. -* Pin a post. Pinning a post makes it appear at the top of the list of posts. - As a result, it is more likely that students will see and respond to pinned - posts. You can write your own post and then pin it, or pin a post by any - author. Click **Pin Thread**. +* Pin a post. Pinning a post makes it appear at the top of the list of posts on + the **Discussion** page. As a result, it is more likely that students will + see and respond to pinned posts. You can write your own post and then pin it, + or pin a post by any author. Select the "More" icon and then **Pin**. .. image:: ../Images/Pin_Discussion.png :alt: Image of the pin icon for discussion posts * Endorse a response. Endorsing a response indicates that it provides value to - the discussion, such as a correct answer to a question. Click the **check - mark** that displays at upper right of the response. + the discussion. Click the "check mark" (or tick mark) icon for the response. .. image:: ../Images/Endorse_Discussion.png :alt: Image of the Endorse button for discussion posts +* Mark a question as answered. You use the same procedure to mark a response as + the correct answer to a question as you do to endorse contributions to a + discussion: click the "check mark" (or tick mark) icon for correct answers. + * Close a post. You can respond to a redundant post by (optionally) pasting in a link to the post that you prefer students to contribute to, and prevent - further interaction by closing the post. Click the **Close** button that - displays below the post to close it. + further interaction by closing the post. Select the "More" icon and then + **Close** to close it. -* Provide post/response/comment guidelines. A set of :ref:`Guidance for - Discussion Moderators` or a post in a course-wide discussion topic (such - as **General**) can provide guidance about when to start a new thread by - adding a post, responding to an existing post, or commenting on a response. +* Provide post/response/comment guidelines. You can post information from the + :ref:`overview` in this chapter, or the :ref:`anatomy + of edX discussions` in the next chapter, + in a course-wide discussion topic (such as General) to provide guidance about + when to start a new thread by adding a post, responding to an existing post, + or commenting on a response. .. _Moderating_discussions: @@ -313,6 +332,11 @@ them available to students as a course handout file or on a defined page in your course. These guidelines can define your expectations and optionally introduce features of edX discussions. +You can also share the :ref:`Discussions for Students and Staff` chapter with +your students. It describes features that are available to all discussion +participants, and may be useful to students who are new to online discussion +forums. + .. For a template that you can use to develop your own guidelines, see .. :ref:`Discussion Forum Guidelines`. @@ -320,8 +344,8 @@ introduce features of edX discussions. Develop a Positive Discussion Culture ======================================== -Monitors can cultivate qualities in their own discussion interactions to make -their influence positive and their time productive. +Discussion monitors can cultivate qualities in their own discussion +interactions to make their influence positive and their time productive. * Encourage quality contributions: thank students whose posts have a positive impact and who answer questions. @@ -352,8 +376,34 @@ their influence positive and their time productive. For a template that you can use to develop guidelines for your course moderators, see :ref:`Guidance for Discussion Moderators`. +.. _Find Question Posts and Discussion Posts: + +========================================== +Find Questions and Discussions +========================================== + +When students create posts, they specify the type of post to indicate whether +they are asking for concrete information (a question) or starting an open-ended +conversation (a discussion). + +On the **Discussion** page, a question mark image identifies posts that ask +questions, and a conversation bubble image identifies posts that start +discussions. When an answer is provided and marked as correct for a question, a +check or tick mark image replaces the question mark image. See :ref:`Answer +Questions`. + +In addition to these visual cues, filters can help you find questions and +discussions that need review. Above the list of posts on the **Discussion** +page, the **Show all** filter is selected by default. You can also select: + +* **Unread**, to list only the discussions and questions that you have not yet + viewed. + +* **Unanswered**, to list only questions that do not yet have any responses + marked as answers. + ================== -Edit Messages +Edit Messages ================== Discussion moderators, community TAs, and admins can edit the content of posts, @@ -364,8 +414,12 @@ text, images, or links. #. Log in to the site and then select the course on your **Current Courses** dashboard. -#. Click the **Edit** button below the post or response or the pencil icon for - the comment. +#. Open the **Discussion** page and then open the post with the content that + requires editing. You can select a single topic from the drop-down list of + discussion topics, apply a filter, or search to locate the post. + +#. For the post or for the response or comment that you want to edit, click the + "More" icon and then **Edit**. #. Remove the problematic portion of the message, or replace it with standard text such as "[REMOVED BY MODERATOR]". @@ -384,33 +438,41 @@ language may need to be deleted, rather than edited. #. Log in to the site and then select the course on your **Current Courses** dashboard. -#. Click the **Delete** button below the post or response or the "X" icon for - the comment. +#. Open the **Discussion** page and then open the post with the content that + requires deletion. You can select a single topic from the drop-down list of + discussion topics, apply a filter, or search to locate the post. + +#. For the post or for the response or comment that you want to delete, click + the "More" icon and then **Delete**. #. Click **OK** to confirm the deletion. .. how to communicate with the poster? -.. important:: If a message is threatening or indicates serious harmful intent, contact campus security at your institution. Report the incident before taking any other action. +.. important:: If a message is threatening or indicates serious harmful + intent, contact campus security at your institution. Report the incident + before taking any other action. ================================== Respond to Reports of Misuse ================================== -Students can use the **Report Misuse** flag to indicate messages that they find -inappropriate. Moderators, community TAs, and admins can check for messages -that have been flagged in this way and edit or delete them as needed. +Students have the option to report contributions that they find inappropriate. +Moderators, community TAs, and admins can check for messages that have been +flagged in this way and edit or delete them as needed. #. View the live version of your course and click **Discussion** at the top of the page. -#. On the drop-down Discussion list click **Flagged Discussions**. +#. In the list of posts on the left side of the page, use the filter drop-down + list (set to **Show all** by default) to select **Flagged**. -#. Review each post listed as a flagged discussion. Posts and responses show a - flag and **Misuse Reported** in red font; comments show only a red flag. +#. Review listed posts. A post is listed if it or any of its responses or + comments has been reported. The reported contribution includes a + **Reported** identifier. -#. Edit or delete the post, response, or comment. Alternatively, to remove the - misuse flag from a message click **Misuse Reported** or the red flag icon. +#. Edit or delete the post, response, or comment. Alternatively, remove the + flag: click the "More" icon and then **Unreport**. =============== Block Users @@ -443,7 +505,9 @@ course units and all of the course-wide topics are affected. and Discussion Community TAs are not affected when you close the discussions for a course. Users with these roles can continue to add to discussions. -.. note:: To assure that your students understand why they cannot add to discussions, you can add the dates that discussions are closed to the **Course Info** page and post them to a General discussion. +.. note:: To assure that your students understand why they cannot add to + discussions, you can add the dates that discussions are closed to the + **Course Info** page and post them to a General discussion. ===================================== Start-End Date Format Specification @@ -518,4 +582,6 @@ reopen: :alt: Same policy value but with a line feed after each bracket and comma, and an indent before each date -.. For examples of email messages that you can send to let students know when the course discussions are closed (or open), see :ref:`Example Messages to Students`. +For examples of email messages that you can send to let students know when the +course discussions are closed (or open), see :ref:`Example Messages to +Students`. \ No newline at end of file diff --git a/docs/en_us/course_authors/source/running_course/discussions_students.rst b/docs/en_us/course_authors/source/running_course/discussions_students.rst index 810b2ea5db..9357a13f90 100644 --- a/docs/en_us/course_authors/source/running_course/discussions_students.rst +++ b/docs/en_us/course_authors/source/running_course/discussions_students.rst @@ -19,11 +19,7 @@ participation more effective. These include ways to: * :ref:`Keep Up with New Activity` -* :ref:`Follow Posts` - -* :ref:`Vote for Posts or Responses` - -* :ref:`Report Discussion Misuse` +* :ref:`React to Contributions` .. _Anatomy of edX Course Discussions: @@ -60,19 +56,23 @@ Discussion Topics ==================================== Most edX courses include opportunities to discuss specific video lectures, -reading assignments, questions, or other course content. Each of these content- -specific discussion opportunities is called a *topic*. When these discussion -topics are included in a course, they typically appear below the content they apply to. +reading assignments, homework problems, or other course content. Each of these +content-specific discussion opportunities is called a *topic*. When these +discussion topics are included in a course, they typically appear below the +content they apply to. .. image:: /Images/Discussion_content_specific.png - :alt: A discussion topic that appears below a video in the course, identified by a "Show Discussion" link + :alt: A discussion topic that appears below a video in the course, identified + by a "Show Discussion" link -Most courses also include one or more topics for discussions about course-wide -areas of interest, such as "Frequently Asked Questions" or "Troubleshooting". -You access these topics on the **Discussion** page of the course. +Most courses also include one or more topics for course-wide discussions, such +as "Frequently Asked Questions" and "Troubleshooting". You access these topics +on the **Discussion** page of the course: click the **All Discussions** +drop-down. .. image:: /Images/Discussion_course_wide.png - :alt: Discussion topics are listed on the Discussion page when you click the drop-down list at the left side of the page + :alt: Discussion topics are listed on the Discussion page when you click the + drop-down list at the left side of the page When you visit the **Discussion** page, you can read and add to any of the discussion topics. @@ -81,14 +81,39 @@ discussion topics. * Content-specific topics are indented under an identifying category name. -Notice that while you can access content-specific topics both on the -**Discussion** page and while you are navigating through course content on the -**Courseware** page, you can only access the course-wide topics on the +Notice that you can access content-specific topics both on the **Discussion** +page and also while you are navigating through course content on the +**Courseware** page. However, you can only access the course-wide topics on the **Discussion** page. -Before you add a post, look through the topics. When add your post to the most -appropriate topic, others with the same interest can find, read, and respond to -it more easily. +Before you add a post, look through the topics. When you add your post to the +most appropriate topic, others with the same interest can find, read, and +respond to it more easily. + +==================================== +Types of Discussion Posts +==================================== + +When you make a contribution to a course discussion topic, it can typically be +categorized as either a question or a discussion. + +* A *question* post raises an issue so that the course staff and community can + provide answers. + +* A *discussion* post starts a conversation by sharing thoughts and + reflections, and inviting community participation. + +When you add a post to a discussion topic, you specify whether it is a question +or a discussion. When you visit the **Discussion** page for your course, a +question mark image identifies posts that ask questions and a conversation +bubble image identifies posts that start discussions. + +.. image:: ../Images/Post_types_in_list.png + :alt: The list of posts with images identifying questions and discussions + +If you have any difficulty deciding which type of post you want to add, think +about whether you want to get concrete information (a question) or start an +open-ended conversation (a discussion). .. _Find Posts: @@ -96,11 +121,12 @@ it more easily. Find Posts ****************************** -Finding out whether someone else has already started a conversation about the -same subject that interests you, and then reading and contributing to that -exchange instead of starting a new one, helps make the time that everyone -spends with the course discussion more productive. You can search for something -specific, or you can browse through the posts in a single discussion topic. +Finding out whether someone else has already asked the same question or +initiated a conversation about the same subject that interests you, and then +reading and contributing to that exchange instead of starting a new one, helps +make the time that everyone spends with the course discussion more productive. +You can search for something specific, or you can browse through the posts in a +single discussion topic. ======================= Search the Discussions @@ -120,8 +146,8 @@ press Enter, the search tries to find: level. * Any usernames that are an exact match to your text. A "Show posts by - {username}" option displays above any posts that have an exact match at any - interaction level. Click the username in the message to read that user's + {username}" option displays above any posts that have an exact text match at + any interaction level. Click the username in the message to read that user's posts, responses, and comments. ============================================== @@ -133,7 +159,22 @@ To review posts about a particular part of the course or type of issue, click down list. (**All Discussions** is selected by default.) Only posts about the topic you select appear in the list of posts. -.. add something about endorsed responses(?) +.. image:: ../Images/Discussion_filters.png + :alt: The list of posts with callouts to identify the top filter to select + one topic and the filter below it to select by state + +======================================= +Review Only Unread or Unanswered Posts +======================================= + +To limit the posts shown on the **Discussion** page, you can select one of the filter options. Above the list of posts, the **Show all** filter +is selected by default. + +* To list only the discussions and questions that you have not yet viewed, + select **Unread**. + +* To list only question posts that do not yet have any responses marked as + answers, select **Unanswered**. .. _Add a Post: @@ -141,14 +182,13 @@ topic you select appear in the list of posts. Add a Post, Response, or Comment ************************************ -.. this section is likely to be more interesting and valuable when we add the discussion vs. question differentiation - ================================ Add a Post ================================ To make sure that other students and the course team can find and respond to -your posts, try to add your posts to the most appropriate topic. +your post, try to select the correct type for your post: either question or +discussion. Add a Post to a Content-Specific Discussion Topic ************************************************** @@ -162,7 +202,7 @@ Add a Post to a Content-Specific Discussion Topic **Show Discussion**. You can scroll through the posts that have already been added: the title and - the first sentence or two of each post appear. To read the entire post, view + the first sentence or two of each post appear. To read an entire post, view the responses to it, and see any comments, click **Expand discussion**. 4. To add a post, click **New Post**. @@ -170,7 +210,9 @@ Add a Post to a Content-Specific Discussion Topic .. image:: /Images/Discussion_content_specific_post.png :alt: Adding a post about specific course content -5. Enter a short, descriptive identifier for your post in the **Title** field. +5. Select the type of post: click **Question** or **Discussion**. + +#. Enter a short, descriptive identifier for your post in the **Title** field. The title is the part of your post that others see when they are browsing on the **Discussion** page or scrolling through one of the content-specific topics. @@ -188,12 +230,14 @@ discussion topics. #. Click **New Post**. +#. Select the type of post: click **Question** or **Discussion**. + #. Select the most appropriate discussion topic for your post. -.. image:: /Images/Discussion_course_wide_post.png - :alt: Selecting the topic for a new post on the Discussion page + .. image:: /Images/Discussion_course_wide_post.png + :alt: Selecting the topic for a new post on the Discussion page -4. Supply a short, descriptive **Title**. The title is the part of your post +5. Supply a short, descriptive **Title**. The title is the part of your post that others see when they are browsing on the **Discussion** page or scrolling through one of the content-specific topics. @@ -223,13 +267,16 @@ Add a Response or Comment to a Content-Specific Discussion Topic #. Scroll to the post where you want to add your thoughts. #. Click **Expand discussion**. + + .. image:: /Images/Discussion_expand.png + :alt: The **Expand discussion** link under a post -#. Add a response or comment. +6. Add a response or comment. - To add a response to the post, click **Add A Response** below the post. When - your response is complete, click **Submit**. + - To add a response to the post, click **Add A Response**. When your response + is complete, click **Submit**. - To add a comment to a response, click in the **Add a comment** field below + - To add a comment to a response, click in the **Add a comment** field below the response. When your comment is complete, click **Submit**. Add a Response or Comment to a Course-Wide Discussion Topic @@ -243,16 +290,18 @@ content-specific discussion topics. #. Find the post that you want to contribute to. To help you decide where to add your thoughts, review the current responses and their comments. -#. Add a response or comment. +#. Add a response or comment. - To add a response to the post, click **Add A Response** below the post. When - your response is complete, click **Submit**. + - To add a response to the post, click **Add A Response**. When your response + is complete, click **Submit**. - To add a comment to a response, click in the **Add a comment** field below + .. image:: /Images/Discussion_add_response.png + :alt: The **Add A Response** button located between a post and its + responses + + - To add a comment to a response, click in the **Add a comment** field below the response. When your comment is complete, click **Submit**. -.. images to come - .. _Keep Up with New Activity: **************************************** @@ -268,21 +317,30 @@ identify posts that are new, or that have responses or comments that you have not read yet, and to distinguish them from exchanges that you have already read completely. -* Posts that you have not read yet have a blue dialog "bubble". +* Posts that you have not read yet have a blue callout image. -* Posts with responses or comments that you have not read yet have a white - dialog "bubble". +* Posts that you have read, but with responses or comments that you have not + read yet, have a white callout image. -* Exchanges that you have read completely have a gray dialog "bubble" and +* Exchanges that you have read completely have a gray callout image and background. .. image:: ../Images/Discussion_colorcoding.png - :alt: The list of posts with posts showing differently colored backgrounds and bubble icons + :alt: The list of posts with posts showing differently colored backgrounds + and callout images -These color-coded dialog bubbles appear when you sort the list of posts by -recent activity or by most activity. If you sort the list of posts by most -votes instead, the number of votes that the post has received appears in place -of the bubble icon. See :ref:`Vote for Posts or Responses`. +The total number of contributions in the exchange (the post and its responses +and comments) appears in each callout image. To see the number of contributions +that you haven't read yet, move your cursor over the callout image. + +.. image:: ../Images/Discussion_mouseover.png + :alt: A post with 4 contributions total and a popup that shows only two are + unread + +The color-coded callout images appear when you sort the list of posts **by +recent activity** or **by most activity**. If you sort the list of posts by +most votes instead, the number of votes that the post has received appears in +place of the callouts. See :ref:`Vote for Posts or Responses`. ============================== Receive Daily Digests @@ -292,37 +350,52 @@ You have the option to receive an email message each day that summarizes discussion activity for the posts you are following. To receive this daily digest, click **Discussion** and then select the **Receive updates** checkbox. -.. _Follow Posts: + +.. _React to Contributions: ************************************ -Follow Posts +Provide Feedback on Contributions ************************************ -If you find a post particularly interesting and want to return to it in the -future, you can follow it: view that post and click the star icon in its top -right corner. +As you read the contributions that other students and staff make to discussion +topics, you can provide feedback without writing a complete response or +comment. You can: -.. image:: ../Images/Discussion_follow.png - :alt: A post with the Follow icon circled +* :ref:`Vote for posts and responses` to provide + positive feedback. -Each post that you follow appears with a "Following" badge in the list of -posts. +* :ref:`Follow posts` so that you can check back in on + interesting conversations and questions easily. -To list only the posts that you are following, regardless of the discussion -topic they apply to, click the drop-down Discussion list and select -**Posts I'm Following**. +* :ref:`Answer questions, and mark your questions as answered`. -.. image:: ../Images/Discussion_filterfollowing.png - :alt: The list of posts with the "Posts I'm Following" filter selected. Every post shows the following badge. +* :ref:`Report a contribution` that is inappropriate + to the course staff. + +To select a feedback option, you use the icons at the top right of each post, +response, or comment. When you move your cursor over these icons a label +appears. + +.. image:: ../Images/Discussion_options_mouseover.png + :alt: The icons at top right of a post, shown before the cursor is + placed over each one and with the Vote, Follow, and More labels + +When you click the "More" icon, a menu of the options that currently apply +appears. + +.. image:: ../Images/Discussion_More_menu.png + :alt: The More icon expanded to show a menu with one option and a menu with + three options .. _Vote for Posts or Responses: -************************************ +============================== Vote for Posts or Responses -************************************ +============================== If you like a post or one of its responses, you can vote for it: view the -post or response and click the **+** at top right. +post or response and click the "Vote" icon at top right. .. image:: ../Images/Discussion_vote.png :alt: A post with the Vote icon circled @@ -332,22 +405,70 @@ the top: click the drop-down list of sorting options and select **by most votes**. .. image:: ../Images/Discussion_sortvotes.png - :alt: The list of posts with the "by most votes" sorting option and the number of votes for the post circled + :alt: The list of posts with the "by most votes" sorting option and the + number of votes for the post circled The number of votes that each post has received displays in the list of posts. (Votes for responses are not included in the number.) +.. _Follow Posts: + +============================== +Follow Posts +============================== + +If you find a post particularly interesting and want to return to it in the +future, you can follow it: view that post and click the "Follow" icon. + +.. image:: ../Images/Discussion_follow.png + :alt: A post with the Follow icon circled + +Each post that you follow appears with a "Following" indicator in the list of +posts. + +To list only the posts that you are following, regardless of the discussion +topic they apply to, click the drop-down Discussion list and select +**Posts I'm Following**. + +.. image:: ../Images/Discussion_filterfollowing.png + :alt: The list of posts with the "Posts I'm Following" filter selected. Every + post in the list shows the following indicator. + +.. _Answer Questions: + +============================================================ +Answer Questions and Mark Questions as Answered +============================================================ + +Anyone in a course can answer questions. Just add a response to the question +post with your answer. + +The person who posted the question (and staff members) can mark responses as +correct: click the "Mark as Answer" icon that appears at upper right of +the response. + +.. image:: ../Images/Discussion_answer_question.png + :alt: A question and a response, with the Mark as Answer icon circled + +After at least one response is marked as the answer, a check or tick mark image +replaces the question mark image for the post in the list on the **Discussion** +page. + +.. image:: ../Images/Discussion_answers_in_list.png + :alt: The list of posts with images identifying unanswered and answered + questions and discussions + .. _Report Discussion Misuse: -************************************ +============================== Report Discussion Misuse -************************************ +============================== You can flag any post, response, or comment for a discussion moderator to -review: view the post or response and then click **Report Misuse**. For a -comment, click the flag. +review: view the contribution, click the "More" icon, and then click +**Report**. .. image:: ../Images/Discussion_reportmisuse.png - :alt: A post and a response with the "Report Misuse" link circled, and a comment with the flag icon circled + :alt: A post and a response with the "Report" link circled .. Future: DOC-121 As a course author, I need a template of discussion guidelines to give to students \ No newline at end of file diff --git a/docs/en_us/data/source/internal_data_formats/change_log.rst b/docs/en_us/data/source/internal_data_formats/change_log.rst index 664ef0265d..fe3d0c7b40 100644 --- a/docs/en_us/data/source/internal_data_formats/change_log.rst +++ b/docs/en_us/data/source/internal_data_formats/change_log.rst @@ -11,6 +11,10 @@ Change Log * - Date - Change + * - 09/02/14 + - Updated the :ref:`Discussion Forums Data` chapter to include the + ``thread_type`` field for CommentThreads and the ``endorsement`` field + for Comments. * - 08/25/14 - Removed information on course grading. See `Establishing a Grading Policy li { - margin: 0 20px 30px; - } - } - - .discussion-show { - display: block; - width: 200px; - margin: auto; - font-size: 14px; - text-align: center; - - &.shown { - .show-hide-discussion-icon { - background-position: 0 0; - } - } - - .show-hide-discussion-icon { - display: inline-block; - position: relative; - top: 5px; - margin-right: 6px; - width: 21px; - height: 19px; - background: url(../images/show-hide-discussion-icon.png) no-repeat; - background-position: -21px 0; - } - } - - .new-post-btn { - display: inline-block; - } - - section.discussion { - margin-top: 20px; - - .threads { - margin-top: 20px; - } - - /* Course content p has a default margin-bottom of 1.416em, this is just to reset that */ - .discussion-thread { - padding: 0; - @include transition(all .25s); - - .dogear, - .vote-btn { - display: none; - } - - &.expanded { - padding: 20px 0; - - .dogear, - .vote-btn { - display: block; - } - - .discussion-article { - border: 1px solid #b2b2b2; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - border-radius: 3px; - } - } - - p { - margin-bottom: 0em; - } - - .discussion-article { - border: 1px solid #ddd; - border-bottom-width: 0; - background: #fff; - min-height: 0; - padding: 10px 10px 15px 10px; - box-shadow: 0 1px 0 #ddd; - @include transition(all .2s); - - .discussion-post { - padding: 12px 20px 0 20px; - @include clearfix; - - header { - padding-bottom: 0; - margin-bottom: 15px; - - h3 { - font-size: 19px; - font-weight: 700; - margin-bottom: 0px; - } - - h4 { - font-size: 16px; - } - } - - .post-body { - font-size: 14px; - clear: both; - } - } - - .post-tools { - margin-left: 20px; - - a { - display: block; - font-size: 12px; - line-height: 30px; - - &.expand-post:before { - content: '▾ '; - } - - &.collapse-post:before { - content: '▴ '; - } - - &.collapse-post { - display: none; - } - } - } - - .responses { - margin-top: 10px; - - header { - padding-bottom: 0em; - margin-bottom: 5px; - - .posted-by { - font-size: 0.8em; - } - } - .response-body { - margin-bottom: 0.2em; - font-size: 14px; - } - } - - .discussion-reply-new { - .wmd-input { - height: 120px; - } - } - - // Content that is hidden by default in the inline view - .post-extended-content{ - display: none; - } - - - } - } - } - - .new-post-article { - display: none; - margin-top: 20px; - - .inner-wrapper { - max-width: 1180px; - min-width: 760px; - margin: auto; - } - - .new-post-form { - width: 100%; - margin-bottom: 20px; - padding: 30px; - border-radius: 3px; - background: rgba(0, 0, 0, .55); - color: #fff; - box-shadow: none; - @include clearfix; - @include box-sizing(border-box); - - .form-row { - margin-bottom: 20px; - } - - .new-post-body .wmd-input { - @include discussion-wmd-input; - position: relative; - width: 100%; - height: 200px; - z-index: 1; - padding: 10px; - box-sizing: border-box; - border: 1px solid #333; - border-radius: 3px 3px 0 0; - background: #fff; - font-family: 'Monaco', monospace; - font-size: 13px; - line-height: 1.6; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-body .wmd-preview { - @include discussion-wmd-preview; - position: relative; - width: 100%; - //height: 50px; - margin-top: -1px; - padding: 25px 20px 10px 20px; - box-sizing: border-box; - border: 1px solid #333; - border-radius: 0 0 3px 3px; - background: #e6e6e6; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-preview-label { - position: absolute; - top: 4px; - left: 4px; - font-size: 11px; - color: #aaa; - text-transform: uppercase; - } - - .new-post-title{ - width: 100%; - height: 40px; - padding: 0 10px; - box-sizing: border-box; - border-radius: 3px; - border: 1px solid #333; - font-size: 16px; - font-weight: 700; - font-family: 'Open Sans', sans-serif; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .submit { - @include blue-button; - float: left; - height: 37px; - margin-top: 10px; - padding-bottom: 2px; - border-color: #333; - - &:hover, &:focus { - border-color: #222; - } - } - - .new-post-cancel { - @include white-button; - float: left; - margin: 10px 0 0 15px; - border-color: #444; - } - - .options { - margin-top: 5px; - - label { - display: inline; - margin-left: 8px; - font-size: 15px; - color: #fff; - text-shadow: none; - } - } - } - - .thread-title { - display: block; - margin-bottom: 20px; - font-size: 21px; - color: #333; - font-weight: 700; - } - } - - .new-post-btn { - @include blue-button; - display: inline-block; - font-size: 13px; - margin-right: 4px; - } - - .new-post-icon { - display: block; - float: left; - width: 16px; - height: 17px; - margin: 8px 7px 0 0; - font-size: 16px; - vertical-align: middle; - color: $white; - } - - .moderator-actions { - padding-left: 0 !important; - } - - section.pagination { - margin-top: 30px; - - nav.discussion-paginator { - float: right; - - ol { - li { - list-style: none; - display: inline-block; - padding-right: 0.5em; - a { - @include white-button; - } - } - - li.current-page{ - height: 35px; - padding: 0 15px; - border: 1px solid #ccc; - border-radius: 3px; - font-size: 13px; - font-weight: 700; - line-height: 32px; - color: #333; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); - } - } - } - } - - .new-post-body { - .wmd-panel { - width: 100%; - min-width: 500px; - } - - .wmd-button-bar { - width: 100%; - } - - .wmd-input { - height: 150px; - width: 100%; - background-color: #e9e9e9; - border: 1px solid #c8c8c8; - font-family: Monaco, 'Lucida Console', monospace; - font-style: normal; - font-size: 0.8em; - line-height: 1.6em; - @include border-radius(3px 3px 0 0); - - &::-webkit-input-placeholder { - color: #888; - } - } - - .wmd-preview { - position: relative; - font-family: $sans-serif; - padding: 25px 20px 10px 20px; - margin-bottom: 5px; - box-sizing: border-box; - border: 1px solid #c8c8c8; - border-top-width: 0; - @include border-radius(0 0 3px 3px); - overflow: hidden; - @include transition(all, .2s, easeOut); - - &:before { - content: 'PREVIEW'; - position: absolute; - top: 3px; - left: 5px; - font-size: 11px; - color: #bbb; - } - - p { - font-family: $sans-serif; - } - background-color: #fafafa; - } - - .wmd-button-row { - position: relative; - margin-left: 5px; - margin-right: 5px; - margin-bottom: 5px; - margin-top: 10px; - padding: 0px; - height: 20px; - overflow: hidden; - @include transition(all, .2s, easeOut); - } - - .wmd-spacer { - width: 1px; - height: 20px; - margin-left: 14px; - - position: absolute; - background-color: Silver; - display: inline-block; - list-style: none; - } - - .wmd-button { - width: 20px; - height: 20px; - padding-left: 2px; - padding-right: 3px; - position: absolute; - display: inline-block; - list-style: none; - cursor: pointer; - background: none; - } - - .wmd-button > span { - display: inline-block; - background-image: url(../images/new-post-icons-full.png); - background-repeat: no-repeat; - background-position: 0px 0px; - width: 20px; - height: 20px; - } - - .wmd-spacer1 { - left: 50px; - } - .wmd-spacer2 { - left: 175px; - } - - .wmd-spacer3 { - left: 300px; - } - - .wmd-prompt-background { - background-color: Black; - } - - .wmd-prompt-dialog { - @extend .modal; - background: #fff; - } - - .wmd-prompt-dialog { - padding: 20px; - - > div { - font-size: 0.8em; - font-family: arial, helvetica, sans-serif; - } - - b { - font-size: 16px; - } - - > form > input[type="text"] { - border-radius: 3px; - color: #333; - } - - > form > input[type="button"] { - border: 1px solid #888; - font-family: $sans-serif; - font-size: 14px; - } - - > form > input[type="file"] { - margin-bottom: 18px; - } - } - } - - .wmd-button-row { - // this is being hidden now because the inline styles to position the icons are not being written - display: none; - position: relative; - height: 12px; - } - - .wmd-button { - span { - background-image: url("/static/images/wmd-buttons.png"); - display: inline-block; - } - } -} \ No newline at end of file diff --git a/lms/static/sass/discussion/_discussion.scss b/lms/static/sass/discussion/_discussion.scss index 903ba66143..db9cd944f5 100644 --- a/lms/static/sass/discussion/_discussion.scss +++ b/lms/static/sass/discussion/_discussion.scss @@ -1,144 +1,7 @@ -// forums - main styling +// forums - main app styling // ==================== - -// mixins and extends -@mixin blue-button { - display: block; - height: 35px; - padding: 0 ($baseline*.75); - border-radius: 3px; - border: 1px solid #2d81ad; - @include linear-gradient(top, #6dccf1, #38a8e5); - font-size: 13px; - font-weight: 700; - line-height: 32px; - color: $white; - text-shadow: 0 1px 0 rgba(0, 0, 0, .3); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - - &:hover, &:focus { - border-color: #297095; - @include linear-gradient(top, #4fbbe4, #2090d0); - } -} - -@mixin white-button { - @include linear-gradient(top, #eee, #ccc); - display: block; - border: 1px solid #aaa; - border-radius: 3px; - padding: 0 ($baseline*.75); - height: 35px; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - color: $dark-gray; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); - font-weight: 700; - font-size: 13px; - line-height: 32px; - - &:hover, &:focus { - @include linear-gradient(top, $white, #ddd); - } -} - -@mixin dark-grey-button { - display: block; - height: 35px; - padding: 0 ($baseline*.75); - border-radius: 3px; - border: 1px solid #222; - background: -webkit-linear-gradient(top, #777, #555); - font-size: 13px; - font-weight: 700; - line-height: 32px; - color: $white; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.6); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - - &:hover, &:focus { - background: -webkit-linear-gradient(top, #888, #666); - } -} - -@mixin discussion-wmd-input { - width: 100%; - height: 240px; - margin-top: 0; - padding: ($baseline/2); - @include box-sizing(border-box); - border: 1px solid #aaa; - border-radius: 3px 3px 0 0; - background: $white; - font-family: 'Monaco', monospace; - font-size: 13px; - line-height: 1.6; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset; -} - -@mixin discussion-wmd-preview-container { - width: 100%; - @include box-sizing(border-box); - border: 1px solid #aaa; - border-top: none; - border-radius: 0 0 3px 3px; - background: #eee; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset; -} - -@mixin discussion-new-post-wmd-preview-container { - @include discussion-wmd-preview-container; - border-color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; -} - -@mixin discussion-wmd-preview-label { - width: 100%; - padding-top: 3px; - padding-left: 5px; - color: #bbb; - font-size: 11px; - text-transform: uppercase; -} - -@mixin discussion-wmd-preview { - width: 100%; - padding: 10px 20px; - color: #333; -} - -@-webkit-keyframes fadeIn { - 0% { opacity: 0.0; } - 100% { opacity: 1.0; } -} - -// =============== - -// main styling body.discussion { - // new post creation - .new-post-form-errors { - display: none; - background: $error-red; - padding: 0; - border: 1px solid $dark-gray; - list-style: none; - color: $white; - line-height: 1.6; - border-radius: 3px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset, 0 1px 0 rgba(255, 255, 255, .2); - - li { - padding: ($baseline/2) $baseline 12px 45px; - border-bottom: 1px solid #dc4949; - background: url(../images/white-error-icon.png) no-repeat 15px 14px; - - &:last-child { - border-bottom: none; - } - } - } - .course-tabs .right { float: right; @@ -155,148 +18,6 @@ body.discussion { } } - .new-post-article { - display: none; - margin-top: $baseline; - - .inner-wrapper { - max-width: 1180px; - min-width: 760px; - margin: auto; - } - - .left-column { - @include box-sizing(border-box); - float: left; - padding: ($baseline*2); - width: 32%; - - .topic-dropdown-label { - font-size: 22px; - font-weight: 700; - color: $white; - text-shadow: none; - } - - .form-topic-drop { - position: relative; - - ul { - list-style: none; - margin: 0; - padding: 0; - } - } - - .form-group-label { - display: block; - padding-top: ($baseline/4); - color: $white; - } - - .topic_dropdown_button { - @include white-button; - position: relative; - z-index: 1000; - margin-top: 15px; - border-color: #444; - height: 40px; - line-height: 36px; - - .drop-arrow { - float: right; - color: #999; - line-height: 37px; - } - } - - .topic_menu_wrapper { - display: none; - position: absolute; - top: $baseline*2; - left: 0; - z-index: 9999; - width: 100%; - @include box-sizing(border-box); - background: #797979; - border: 1px solid $dark-gray; - box-shadow: 0 2px 50px rgba(0, 0, 0, .4); - } - - .topic_menu { - max-height: 400px; - overflow-y: scroll; - - a { - display: block; - padding: ($baseline/2) 15px; - border-top: 1px solid #5f5f5f; - font-size: 14px; - font-weight: 700; - line-height: 18px; - color: #eee; - @include transition(none); - - &:hover, &:focus { - background-color: #666; - } - - .topic-menu-span { - color: #eee; - } - } - - li li { - a { - padding-left: 39px; - background: url(../images/nested-icon.png) no-repeat 17px 10px; - } - } - - li li li { - a { - padding-left: 63px; - background: url(../images/nested-icon.png) no-repeat 41px 10px; - } - } - } - - .topic_menu_search { - padding: $baseline/2; - border-bottom: 1px solid black; - } - - .form-topic-drop-search-input { - width: 100%; - height: 30px; - padding: 0 15px; - @include box-sizing(border-box); - border-radius: 30px; - border: 1px solid $dark-gray; - box-shadow: 0 1px 3px rgba(0, 0, 0, .25) inset; - background: -webkit-linear-gradient(top, #eee, $white); - font-size: 11px; - line-height: 16px; - color: #333; - } - } - - .right-column { - float: left; - width: 68%; - padding: ($baseline*2); - @include box-sizing(border-box); - } - - .wmd-button { - background: none; - } - - .wmd-button span { - background: url(../images/new-post-icons-full.png) no-repeat; - } - } - .edit-post-form { @include clearfix; margin-bottom: ($baseline*2); @@ -341,92 +62,12 @@ body.discussion { font-size: 16px; font-family: $sans-serif; } - } .comments .edit-post-form h1 { @extend %t-title6; } - .new-post-form { - @include clearfix; - border-radius: 3px; - width: 100%; - background: $shadow-d2; - box-shadow: 0 1px 2px $shadow-d2 inset, 0 1px 0 rgba(255, 255, 255, .5); - color: $white; - - .form-row { - margin-bottom: $baseline; - } - - .new-post-body .wmd-input { - @include discussion-wmd-input; - @include box-sizing(border-box); - position: relative; - z-index: 1; - width: 100%; - height: 150px; - background: $white; - } - - .new-post-body .wmd-preview-container { - @include discussion-new-post-wmd-preview-container; - } - - .new-post-body .wmd-preview-label { - @include discussion-wmd-preview-label; - } - - .new-post-body .wmd-preview { - @include discussion-wmd-preview; - } - - .new-post-title { - @include box-sizing(border-box); - border: 1px solid $dark-gray; - border-radius: 3px; - padding: 0 ($baseline/2); - width: 100%; - height: 40px; - box-shadow: 0 1px 3px $shadow inset; - color: $dark-gray; - font-weight: 700; - } - - .submit { - @include blue-button; - float: left; - margin-top: ($baseline/2); - border-color: $dark-gray; - padding-bottom: ($baseline/10); - height: 37px; - - &:hover, &:focus { - border-color: #222; - } - } - - .new-post-cancel { - @include white-button; - float: left; - margin: ($baseline/2) 0 0 ($baseline*.75); - border-color: #444; - } - - .options { - margin-top: ($baseline*2); - - label { - display: inline; - margin-left: ($baseline/2); - color: $white; - text-shadow: none; - font-size: 15px; - } - } - } - .thread-title { display: block; margin-bottom: $baseline; @@ -467,9 +108,6 @@ body.discussion { } } - - - .wmd-panel { min-width: 500px; width: 100%; @@ -776,7 +414,7 @@ body.discussion { .discussion-article { position: relative; - min-height: 468px; + min-height: 500px; background-image: url(../images/bg-texture.png); a { @@ -784,7 +422,7 @@ body.discussion { } h1 { - margin-bottom: $baseline/2; + margin-bottom: ($baseline/4); font-size: 28px; font-weight: 700; letter-spacing: 0; @@ -793,17 +431,14 @@ body.discussion { .posted-details { font-size: 12px; - font-style: italic; color: #888; .username { - display: block; - font-size: 16px; font-weight: 700; } - span { - font-style: italic; + .timeago, .top-post-status { + color: inherit; } } @@ -816,43 +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) $baseline ($baseline*2); - box-shadow: 0 1px 3px $shadow; - background-color: $white; - border-radius: 3px 3px 0 0; - - > header .vote-btn { - position: relative; - z-index: 100; - margin-top: ($baseline/4); - margin-left: ($baseline*2); - } - - .post-tools { - @include clearfix; - margin-top: 15px; - } } .discussion-post header, @@ -860,9 +458,11 @@ body.discussion { margin-bottom: $baseline; } - - .responses { + &:empty { + display: none; + } + list-style: none; margin-top: $baseline; padding: 0px ($baseline*2); @@ -917,7 +517,6 @@ body.discussion { text-transform: uppercase; } - &.loading { height: 0; margin: 0; @@ -930,7 +529,7 @@ body.discussion { .discussion-response { @include box-sizing(border-box); border-radius: 3px 3px 0 0; - padding: $baseline $baseline 0; + padding: $baseline; background-color: $white; } .posted-by { @@ -959,79 +558,6 @@ body.discussion { } } - .vote-btn { - position: relative; - z-index: 100; - float: right; - display: block; - height: 27px; - padding: 0 8px; - border-radius: 5px; - border: 1px solid #b2b2b2; - @include linear-gradient(top, $white 35%, #ebebeb); - box-shadow: 0 1px 1px rgba(0, 0, 0, .15); - font-size: 12px; - font-weight: 700; - line-height: 25px; - color: #333; - - .plus-icon { - display: inline-block; - width: 10px; - height: 10px; - margin: 8px 6px 0 0; - background: url(../images/vote-plus-icon.png) no-repeat; - font-size: 18px; - text-indent: -9999px; - color: #17b429; - overflow: hidden; - } - - &.is-cast { - border-color: #379a42; - @include linear-gradient(top, #50cc5e, #3db84b); - color: $white; - text-shadow: 0 1px 0 rgba(0, 0, 0, .3); - box-shadow: 0 1px 0 rgba(255, 255, 255, .4) inset, 0 1px 2px $shadow; - - .plus-icon { - background-position: 0 -10px; - color: #336a39; - text-shadow: 0 1px 0 rgba(255, 255, 255, .4); - } - } - } - - .endorse-btn { - display: block; - float: right; - width: 27px; - height: 27px; - margin-right: ($baseline/2); - border-radius: 27px; - border: 1px solid #a0a0a0; - @include linear-gradient(top, $white 35%, $gray-l4); - box-shadow: 0 1px 1px $shadow-l1; - - .check-icon { - display: block; - width: 13px; - height: 12px; - margin: 8px auto; - background: url(../images/endorse-icon.png) no-repeat; - } - - &.is-endorsed { - border: 1px solid #4697c1; - @include linear-gradient(top, #6dccf1, #38a8e5); - box-shadow: 0 1px 1px $shadow-l1, 0 1px 0 rgba(255, 255, 255, .4) inset; - - .check-icon { - background-position: 0 -12px; - } - } - } - blockquote { background: $gray-l5; border-radius: 3px; @@ -1039,89 +565,6 @@ body.discussion { font-size: 14px; } - .comments { - margin: 0; - border-radius: 0 0 3px 3px; - padding: 0; - background: $gray-l6; - box-shadow: 0 1px 3px -1px $shadow inset; - list-style: none; - - > li { - border-top: 1px solid $gray-l4; - padding: ($baseline/2) $baseline; - } - - - blockquote { - background: $gray-l4; - border-radius: 3px; - padding: ($baseline/4) ($baseline/2); - font-size: 14px; - } - - .comment-form { - @include clearfix; - - .comment-form-input { - padding: ($baseline/4) ($baseline/2); - background-color: $white; - font-size: 14px; - } - - .discussion-submit-comment { - @include blue-button; - float: left; - margin-top: 8px; - } - - .wmd-input { - height: 40px; - } - - .discussion-errors { - margin: 0; - } - } - - .response-body { - font-size: 13px; - margin-bottom: ($baseline/2); - - p + p { - margin-top: 12px; - } - } - - .posted-details { - font-size: 11px; - } - - .staff-label { - margin-left: ($baseline/10); - padding: 0 ($baseline/5); - border-radius: 2px; - background: #009FE2; - font-size: 9px; - font-weight: 700; - font-style: normal; - color: white; - text-transform: uppercase; - } - } - - .community-ta-label{ - margin-left: ($baseline/10); - padding: 0 ($baseline/5); - border-radius: 2px; - background: $forum-color-community-ta; - font-size: 9px; - font-weight: 700; - font-style: normal; - color: white; - text-transform: uppercase; - } - .comment-form { padding: ($baseline/2) 0; @@ -1153,85 +596,11 @@ body.discussion { } } - .moderator-actions { - margin: 0; - padding: $baseline 0; - @include clearfix; - - li { - float: left; - margin-right: ($baseline/2); - list-style: none; - } - - a { - @include white-button; - height: 26px; - @include linear-gradient(top, $white 35%, #ebebeb); - font-size: 13px; - line-height: 24px; - color: #737373; - font-weight: normal; - box-shadow: 0 1px 1px $shadow-l1; - - &:hover, &:focus { - @include linear-gradient(top, $white 35%, #ddd); - } - - .delete-icon { - display: block; - float: left; - width: 10px; - height: 10px; - margin: 8px 4px 0 0; - background: url(../images/moderator-delete-icon.png) no-repeat; - } - - .edit-icon { - display: block; - float: left; - width: 10px; - height: 10px; - margin: 7px 4px 0 0; - background: url(../images/moderator-edit-icon.png) no-repeat; - } - } - } - - - - .main-article.new { display: none; padding: ($baseline*2.5); } - .new-post-form { - margin-top: $baseline; - @include clearfix; - } - - .new-post-form .submit { - @include blue-button; - float: left; - margin-top: ($baseline/2); - padding-bottom: ($baseline/10); - } - - .new-post-form .options { - float: right; - margin-top: $baseline; - font-size: 14px; - - label { - margin-left: ($baseline/5); - } - } - - - - - .discussion-reply-new { padding: $baseline ($baseline*1.5); @include clearfix; @@ -1279,16 +648,6 @@ body.discussion { // ==================== -// post actions -global -.global-discussion-actions { - height: 60px; - @include linear-gradient(top, #ebebeb, #d9d9d9); - border-radius: 0 3px 0 0; - border-bottom: 1px solid #bcbcbc; -} - -// ==================== - // inline discussion module and profile thread styling .discussion-module { @extend .discussion-body; @@ -1372,16 +731,6 @@ body.discussion { margin-bottom: $baseline; @include transition(all .25s linear 0s); - .dogear { - display: none; - } - - &.expanded { - .dogear{ - display: block; - } - } - p { margin-bottom: 0; } @@ -1403,7 +752,8 @@ body.discussion { max-height: 600px; .group-visibility-label { - margin: $baseline ($baseline*1.5) ($baseline*-0.5); + font-weight: 400; + margin-bottom: ($baseline*0.5); } .discussion-post { @@ -1439,10 +789,10 @@ body.discussion { } } - h3 { + h1 { font-size: 19px; font-weight: 700; - margin-bottom: 0px; + margin-bottom: 0px !important; // Override courseware CSS } h4 { @@ -1524,97 +874,6 @@ body.discussion { margin: auto; } - .new-post-form { - width: 100%; - margin-bottom: $baseline; - padding: 30px; - border-radius: 3px; - background: rgba(0, 0, 0, .55); - color: $white; - box-shadow: none; - @include clearfix; - @include box-sizing(border-box); - - .form-row { - margin-bottom: $baseline; - } - - .new-post-body .wmd-input { - @include discussion-wmd-input; - position: relative; - width: 100%; - height: 200px; - z-index: 1; - padding: $baseline/2; - @include box-sizing(border-box); - border: 1px solid #333; - border-radius: 3px 3px 0 0; - background: $white; - font-family: 'Monaco', monospace; - font-size: 13px; - line-height: 1.6; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-body .wmd-preview-container { - @include discussion-new-post-wmd-preview-container; - } - - .new-post-body .wmd-preview-label { - @include discussion-wmd-preview-label; - } - - .new-post-body .wmd-preview { - @include discussion-wmd-preview; - } - - .new-post-title{ - width: 100%; - height: 40px; - padding: 0 $baseline/2; - @include box-sizing(border-box); - border-radius: 3px; - border: 1px solid #333; - font-size: 16px; - font-weight: 700; - font-family: 'Open Sans', sans-serif; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .submit { - @include blue-button; - float: left; - height: 37px; - margin-top: $baseline/2; - padding-bottom: 2px; - border-color: #333; - - &:hover, &:focus { - border-color: #222; - } - } - - .new-post-cancel { - @include white-button; - float: left; - margin: $baseline/2 0 0 15px; - border-color: #444; - } - - .options { - margin-top: 5px; - - label { - display: inline; - margin-left: 8px; - font-size: 15px; - color: $white; - text-shadow: none; - } - } - } - .thread-title { display: block; margin-bottom: $baseline; @@ -1643,10 +902,6 @@ body.discussion { color: $white; } - .moderator-actions { - padding-left: 0 !important; - } - section.pagination { margin-top: 30px; @@ -1678,148 +933,6 @@ body.discussion { } } - .new-post-body { - .wmd-panel { - width: 100%; - min-width: 500px; - } - - .wmd-button-bar { - width: 100%; - } - - .wmd-input { - height: 150px; - width: 100%; - background-color: #e9e9e9; - border: 1px solid #c8c8c8; - font-family: Monaco, 'Lucida Console', monospace; - font-style: normal; - font-size: 0.8em; - line-height: 1.6em; - border-radius: 3px 3px 0 0; - - &::-webkit-input-placeholder { - color: #888; - } - } - - .wmd-button-row { - position: relative; - margin: ($baseline/2) ($baseline/4) ($baseline/4) ($baseline/4); - padding: 0; - height: 30px; - overflow: hidden; - @include transition(all .2s ease-out 0s); - } - - .wmd-spacer { - width: 1px; - height: 20px; - margin-left: 14px; - - position: absolute; - background-color: Silver; - display: inline-block; - list-style: none; - } - - .wmd-button { - width: 20px; - height: 20px; - padding-left: 2px; - padding-right: 3px; - position: absolute; - display: inline-block; - list-style: none; - cursor: pointer; - background: none; - } - - .wmd-button > span { - display: inline-block; - background-image: url(../images/new-post-icons-full.png); - background-repeat: no-repeat; - background-position: 0px 0px; - width: 20px; - height: 20px; - } - - .wmd-spacer1 { - left: 50px; - } - .wmd-spacer2 { - left: 175px; - } - - .wmd-spacer3 { - left: 300px; - } - - .wmd-prompt-background { - background-color: Black; - } - - .wmd-prompt-dialog { - @extend .modal; - background: $white; - } - - .wmd-prompt-dialog { - padding: $baseline; - - > div { - font-size: 0.8em; - font-family: arial, helvetica, sans-serif; - } - - b { - font-size: 16px; - } - - > form > input[type="text"] { - border-radius: 3px; - color: #333; - } - - > form > input[type="button"] { - border: 1px solid #888; - font-family: $sans-serif; - font-size: 14px; - } - - > form > input[type="file"] { - margin-bottom: 18px; - } - } - } - - .wmd-button-row { - // this is being hidden now because the inline styles to position the icons are not being written - position: relative; - height: 25px; - } - - .wmd-button { - span { - width: 20px; - height: 20px; - background-image: url("/static/images/wmd-buttons.png"); - display: inline-block; - } - } - - .wmd-spacer1 { - left: 50px; - } - - .wmd-spacer2 { - left: 175px; - } - - .wmd-spacer3 { - left: 300px; - } .edit-post-form { width: 100%; margin-bottom: $baseline; @@ -1864,102 +977,11 @@ body.discussion { } .discussion-user-threads { - @extend .discussion-module -} + @extend .discussion-module; -// ==================== - -// post actions - pinning -.discussion-pin { - font-size: 12px; - float:right; - padding-right: 5px; - font-style: italic; - margin-right: $baseline/2; - opacity: 0.8; - - &.admin-pin { - cursor: pointer; - - &:hover, &:focus { - @include transition(opacity .2s linear 0s); - opacity: 1.0; - } - } - } - -.discussion-pin-inline { - font-size: 12px; - float:right; - font-style: italic; - position: relative; - right:-20px; - top:-13px; - margin-right:35px; - margin-top:13px; - opacity: 1.0; -} - -.notpinned .icon { - display: block; - float: left; - margin: 3px; - width: 10px; - height: 14px; - padding-right: 3px; - color: #333; -} - -.pinned .icon { - display: block; - float: left; - margin: 3px; - width: 10px; - height: 14px; - padding-right: 3px; - color: $pink; -} - -.pinned span { - color: $pink; - font-style: italic; -} - -.notpinned span { - color: #333; - font-style: italic; -} - -.pinned-false -{ -display:none; -} - -// ==================== - -// post actions - flagging -.discussion-flag-abuse, .discussion-delete-comment, .discussion-edit-comment { - font-size: 12px; - float:right; - margin-left: ($baseline/2); - font-style: italic; - cursor:pointer; - color: $dark-gray; - opacity: 0.8; - - &:hover, &:focus { - @include transition(opacity .2s linear 0s); - opacity: 1.0; + .discussion-post { + padding-bottom: $baseline !important; } - - .flag-label { - font-style: italic; - margin-left: ($baseline/4); - } -} - -.flagged * { - color: $pink; } // ==================== diff --git a/lms/static/sass/discussion/_mixins.scss b/lms/static/sass/discussion/_mixins.scss new file mode 100644 index 0000000000..0e463cb71f --- /dev/null +++ b/lms/static/sass/discussion/_mixins.scss @@ -0,0 +1,156 @@ +// discussion - mixins and extends +// ==================== + +@mixin blue-button { + @include linear-gradient(top, #6dccf1, #38a8e5); + display: block; + border: 1px solid #2d81ad; + border-radius: 3px; + padding: 0 ($baseline*.75); + height: 35px; + color: $white; + text-shadow: none; + font-size: 13px; + line-height: 35px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); + + &:hover, &:focus { + @include linear-gradient(top, #4fbbe4, #2090d0); + border-color: #297095; + } +} + +@mixin white-button { + @include linear-gradient(top, $white, $gray-l5); + display: block; + border: 1px solid #aaa; + border-radius: 3px; + padding: 0 ($baseline*.75); + height: 35px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); + color: $dark-gray; + text-shadow: none; + font-size: 13px; + line-height: 35px; + + &:hover, &:focus { + @include linear-gradient(top, $white, $gray-l6); + } +} + +@mixin dark-grey-button { + display: block; + border: 1px solid #222; + border-radius: 3px; + padding: 0 ($baseline*.75); + height: 35px; + background: -webkit-linear-gradient(top, #777, #555); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); + color: $white; + text-shadow: none; + font-size: 13px; + line-height: 35px; + + &:hover, &:focus { + background: -webkit-linear-gradient(top, #888, #666); + } +} + +@mixin discussion-wmd-input { + @include box-sizing(border-box); + margin-top: 0; + border: 1px solid #aaa; + border-radius: 3px 3px 0 0; + padding: ($baseline/2); + width: 100%; + height: 240px; + background: $white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset; + font-size: 13px; + font-family: 'Monaco', monospace; + line-height: 1.6; +} + +@mixin discussion-wmd-preview-container { + @include box-sizing(border-box); + border: 1px solid #aaa; + border-top: none; + border-radius: 0 0 3px 3px; + width: 100%; + background: #eee; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset; +} + +@mixin discussion-new-post-wmd-preview-container { + @include discussion-wmd-preview-container; + border-color: #333; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; +} + +@mixin discussion-wmd-preview-label { + padding-top: 3px; + padding-left: 5px; + width: 100%; + color: #bbb; + text-transform: uppercase; + font-size: 11px; +} + +@mixin discussion-wmd-preview { + padding: 10px 20px; + width: 100%; + color: #333; +} + +@-webkit-keyframes fadeIn { + 0% { opacity: 0.0; } + 100% { opacity: 1.0; } +} + +// extends - content - text overflow by ellipsis +%cont-truncated { + @include box-sizing(border-box); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +@mixin forum-post-label($color) { + @extend %t-weight4; + @include font-size(9); + display: inline; + margin-top: ($baseline/4); + border: 1px solid; + border-radius: 3px; + padding: 1px 6px; + text-transform: uppercase; + white-space: nowrap; + + border-color: $color; + color: $color; + + .icon { + margin-right: ($baseline/5); + } + + &:last-child { + margin-right: 0; + } + + &.is-hidden { + display: none; + } +} + +@mixin forum-user-label($color) { + @include font-size(9); + @extend %t-weight5; + vertical-align: middle; + margin-left: ($baseline/4); + border-radius: 2px; + padding: 0 ($baseline/5); + background: $color; + font-style: normal; + text-transform: uppercase; + color: white; +} 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/_editor.scss b/lms/static/sass/discussion/elements/_editor.scss new file mode 100644 index 0000000000..0d5a374a4d --- /dev/null +++ b/lms/static/sass/discussion/elements/_editor.scss @@ -0,0 +1,168 @@ +// discussion - elements - editor +// ==================== + +// UI: general editor styling + +// TO-DO: isolate out all editing styling from _discussion.scss and clean up cases defined below once general syling exists + +// ========================= + +// CASE: new post +.forum-new-post-form { + .wmd-input { + @include discussion-wmd-input; + @include box-sizing(border-box); + position: relative; + z-index: 1; + width: 100%; + height: 150px; + background: $white; + } + + .wmd-preview-container { + @include discussion-new-post-wmd-preview-container; + } + + .wmd-preview-label { + @include discussion-wmd-preview-label; + } + + .wmd-preview { + @include discussion-wmd-preview; + } + + .wmd-button { + background: none; + } +} + +// ========================= + +// CASE: inline styling +// TO-DO: additional styling cleanup here necessary, for now this case was ported over from _discussion.scss +.discussion-module { + + .wmd-panel { + width: 100%; + min-width: 500px; + } + + .wmd-button-bar { + width: 100%; + } + + .wmd-input { + width: 100%; + height: 150px; + border-radius: 3px 3px 0 0; + font-style: normal; + font-size: 0.8em; + font-family: Monaco, 'Lucida Console', monospace; + line-height: 1.6em; + + &::-webkit-input-placeholder { + color: #888; + } + } + + .wmd-button-row { + @include transition(all .2s ease-out 0s); + position: relative; + overflow: hidden; + margin: ($baseline/2) ($baseline/4) ($baseline/4) ($baseline/4); + padding: 0; + height: 30px; + } + + .wmd-spacer { + position: absolute; + display: inline-block; + margin-left: 14px; + width: 1px; + height: 20px; + background-color: Silver; + list-style: none; + } + + .wmd-button { + position: absolute; + display: inline-block; + padding-right: 3px; + padding-left: 2px; + width: 20px; + height: 20px; + background: none; + list-style: none; + cursor: pointer; + } + + .wmd-button > span { + display: inline-block; + width: 20px; + height: 20px; + background-image: url('/static/images/wmd-buttons-transparent.png'); + background-position: 0px 0px; + background-repeat: no-repeat; + } + + .wmd-spacer1 { + left: 50px; + } + .wmd-spacer2 { + left: 175px; + } + + .wmd-spacer3 { + left: 300px; + } + + .wmd-prompt-background { + background-color: Black; + } + + .wmd-prompt-dialog { + @extend .modal; + background: $white; + } + + .wmd-prompt-dialog { + padding: $baseline; + + > div { + font-size: 0.8em; + font-family: arial, helvetica, sans-serif; + } + + b { + font-size: 16px; + } + + > form > input[type="text"] { + border-radius: 3px; + color: #333; + } + + > form > input[type="button"] { + border: 1px solid #888; + font-family: $sans-serif; + font-size: 14px; + } + + > form > input[type="file"] { + margin-bottom: 18px; + } + } + + .wmd-button-row { + // this is being hidden now because the inline styles to position the icons are not being written + position: relative; + height: 25px; + } + + .wmd-button { + span { + background-image: url("/static/images/wmd-buttons.png"); + display: inline-block; + } + } +} 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 cdb15d324e..76b33427e9 100644 --- a/lms/static/sass/discussion/elements/_navigation.scss +++ b/lms/static/sass/discussion/elements/_navigation.scss @@ -1,3 +1,6 @@ +// discussion - elements - navigation +// ==================== + .forum-nav { @include box-sizing(border-box); float: left; @@ -124,23 +127,37 @@ background-color: $gray-l5; padding: ($baseline/4) ($baseline/2); color: $black; + text-align: right; +} + +.forum-nav-filter-main { + @include box-sizing(border-box); + display: inline-block; + width: 50%; + text-align: left; +} + +.forum-nav-filter-cohort, .forum-nav-sort { + @include box-sizing(border-box); + display: inline-block; + width: 50%; + text-align: right; } %forum-nav-select { border: none; max-width: 100%; background-color: transparent; - font: inherit; +} + +.forum-nav-filter-main-control { + @extend %forum-nav-select; } .forum-nav-filter-cohort-control { @extend %forum-nav-select; } -.forum-nav-sort { - float: right; -} - .forum-nav-sort-control { @extend %forum-nav-select; } @@ -176,14 +193,35 @@ vertical-align: middle; } +.forum-nav-thread-wrapper-0 { + @extend %forum-nav-thread-wrapper; + width: 7%; + + .icon { + @include font-size(14); + } + + .icon-comments { + color: $gray-l2; + } + + .icon-ok { + color: $forum-color-marked-answer; + } + + .icon-question { + color: $pink; + } +} + .forum-nav-thread-wrapper-1 { @extend %forum-nav-thread-wrapper; - width: 70%; + width: 80%; } .forum-nav-thread-wrapper-2 { @extend %forum-nav-thread-wrapper; - width: 30%; + width: 13%; text-align: right; } @@ -192,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; @@ -249,11 +242,6 @@ } } -.forum-nav-thread-endorsed { - @extend %forum-nav-thread-wrapper-2-content; - color: $green-d1; -} - .forum-nav-thread-votes-count { @extend %forum-nav-thread-wrapper-2-content; } diff --git a/lms/static/sass/discussion/utilities/_shame.scss b/lms/static/sass/discussion/utilities/_shame.scss index 347ae0d683..af92ff2e5a 100644 --- a/lms/static/sass/discussion/utilities/_shame.scss +++ b/lms/static/sass/discussion/utilities/_shame.scss @@ -66,9 +66,16 @@ // navigation - sort and filter bar // -------------------------------- -// Override global span rules -.forum-nav-sort-label { - color: inherit; +// Override global label rules +.forum-nav-filter-main, .forum-nav-filter-cohort, .forum-nav-sort { + font: inherit; + line-height: 1em; + margin-bottom: 0; +} + +// Override global select rules +.forum-nav-filter-main-control, .forum-nav-filter-cohort-control, .forum-nav-sort-control { + font: inherit; } // -------------------------------- @@ -95,3 +102,55 @@ li[class*=forum-nav-thread-label-] { display: none !important; } } + +// ------------- +// new post form +// ------------- + +.forum-new-post-form { + // Override global label rules + .post-type { + text-shadow: none; + } + + .post-type, .topic-filter-label { + margin-bottom: 0; + } + + // Override global ul rules + .topic-menu { + padding-left: 0; + } + + .topic-menu, .topic-submenu { + margin-top: 0; + margin-bottom: 0; + } + + // Override global span rules + .post-topic-button .drop-arrow { + line-height: 36px; + } + + .topic-title { + line-height: 14px; + } +} + +// ------- +// Actions +// ------- + +.discussion.container, .discussion-module { + + // Override courseware + .post-actions-list, .response-actions-list, .comment-actions-list { + @extend %t-copy-sub2; + padding-left: 0 !important; + } + + // Override global span + .action-label span, .action-icon span { + color: inherit; + } +} diff --git a/lms/static/sass/discussion/utilities/_variables.scss b/lms/static/sass/discussion/utilities/_variables.scss index 4009d74d3b..d9d296734c 100644 --- a/lms/static/sass/discussion/utilities/_variables.scss +++ b/lms/static/sass/discussion/utilities/_variables.scss @@ -1,5 +1,8 @@ $forum-color-active-thread: tint($blue, 85%); $forum-color-pinned: $pink; +$forum-color-reported: $pink; +$forum-color-closed: $black; $forum-color-following: $blue; $forum-color-staff: $blue; $forum-color-community-ta: $green-d1; +$forum-color-marked-answer: $green-d1; diff --git a/lms/static/sass/discussion/views/_new-post.scss b/lms/static/sass/discussion/views/_new-post.scss new file mode 100644 index 0000000000..db80cd986b --- /dev/null +++ b/lms/static/sass/discussion/views/_new-post.scss @@ -0,0 +1,258 @@ +// discussion - views - new post +// ==================== + +// UI: form structure +.forum-new-post-form { + @include clearfix; + box-sizing: border-box; + margin: 0; + border-radius: 3px; + padding: ($baseline*2); + min-width: 760px; + max-width: 1180px; + background: $gray-l5; + + .post-field { + margin-bottom: $baseline; + + .field-label { + display: inline-block; + width: 50%; + vertical-align: top; + line-height: 40px; + + .field-input { + display: inline-block; + width: 100%; + vertical-align: top; + } + + .field-label-text { + display: inline-block; + width: 25%; + vertical-align: top; + text-transform: uppercase; + font-size: 12px; + line-height: 40px; + } + + .field-label-text + .field-input { + width: 75%; + } + } + + // UI: support text for input fields + .field-help { + @include box-sizing(border-box); + display: inline-block; + padding-left: $baseline; + width: 50%; + font-size: 12px; + } + } + + .post-options { + margin-bottom: ($baseline/2); + } +} + +// CASE: inline styling +.discussion-module .forum-new-post-form { + background: $white; +} + +// ==================== + +// UI: inputs +.forum-new-post-form { + .post-topic-button { + @include white-button; + @extend %cont-truncated; + z-index: 1000; + padding: 0 $baseline 0 ($baseline*.75); + height: 40px; + font-size: 14px; + line-height: 36px; + + .drop-arrow { + float: right; + color: #999; + } + } + + .post-type-input { + @extend %text-sr; + } + + .post-type-label { + @extend %cont-truncated; + @include box-sizing(border-box); + @include white-button; + @include font-size(14); + display: inline-block; + padding: 0 ($baseline/2); + width: 48%; + height: 40px; + text-align: center; + color: $gray-d3; + font-weight: 600; + line-height: 36px; + + .icon { + margin-right: 5px; + } + } + + .post-type-input:checked + .post-type-label { + background-color: $forum-color-active-thread; + background-image: none; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset; + } + + .post-type-input:focus + .post-type-label { + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.4) inset, 0 0 2px 2px $blue; + } + + input[type=text].field-input { + @include box-sizing(border-box); + border: 1px solid $gray-l2; + border-radius: 3px; + padding: 0 $baseline/2; + height: 40px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15) inset; + color: #333; + font-weight: 700; + font-size: 16px; + font-family: 'Open Sans', sans-serif; + } + + .post-option { + @include box-sizing(border-box); + display: inline-block; + margin-right: $baseline; + border: 1px solid transparent; + border-radius: 3px; + padding: ($baseline/2); + + &:hover { + border-color: $gray-l3; + } + + &.is-enabled { + border-color: $blue; + color: $blue; + } + + .post-option-input { + margin-right: ($baseline/2); + } + + .icon { + margin-right: 0.5em; + } + } +} + +// ==================== + +// UI: actions +.forum-new-post-form { + .submit { + @include blue-button; + display: inline-block; + margin-right: ($baseline/2); + } + + .cancel { + @include white-button; + display: inline-block; + } +} + +// ==================== + +// UI: errors - new post creation +.forum-new-post-form { + .post-errors { + margin-bottom: $baseline; + border-radius: 3px; + padding: 0; + background: $error-red; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) inset, 0 1px 0 rgba(255, 255, 255, .2); + color: $white; + list-style: none; + + .post-error { + padding: ($baseline/2) $baseline 12px 45px; + border-bottom: 1px solid $red; + background: url(../images/white-error-icon.png) no-repeat 15px 14px; + + &:last-child { + border-bottom: none; + } + } + } +} + +// ==================== + +// UI: topic menu + +// TO-DO: refactor to use _navigation.scss as general topic selector +.forum-new-post-form .post-topic { + position: relative; + + .topic-menu-wrapper { + @include box-sizing(border-box); + position: absolute; + top: 40px; + left: 0; + z-index: 9999; + border: 1px solid $gray-l3; + width: 100%; + background: $white; + box-shadow: 0 2px 1px $shadow; + } + + .topic-filter-label { + border-bottom: 1px solid $gray-l2; + padding: ($baseline/4); + } + + .topic-filter-input { + @include box-sizing(border-box); + border: 1px solid $gray-l3; + padding: 0 15px; + width: 100%; + height: 30px; + color: #333; + font-size: 11px; + line-height: 16px; + } + + .topic-menu { + overflow-y: scroll; + max-height: 400px; + list-style: none; + } + + .topic-submenu { + padding-left: $baseline; + list-style: none; + } + + .topic-title { + display: block; + border-bottom: 1px solid $gray-l3; + padding: ($baseline/2); + font-size: 14px; + } + + a.topic-title { + @include transition(none); + + &:hover, &:focus { + background-color: $gray-l4; + } + } +} diff --git a/lms/static/sass/discussion/views/_response.scss b/lms/static/sass/discussion/views/_response.scss new file mode 100644 index 0000000000..72ea60c55f --- /dev/null +++ b/lms/static/sass/discussion/views/_response.scss @@ -0,0 +1,9 @@ +.forum-response .action-show-comments { + @include box-sizing(border-box); + @include font-size(13); + display: block; + padding: ($baseline/2) $baseline; + width: 100%; + background: $gray-l6; + box-shadow: 0 1px 3px -1px $shadow inset; +} diff --git a/lms/static/sass/discussion/views/_thread.scss b/lms/static/sass/discussion/views/_thread.scss new file mode 100644 index 0000000000..71a7da7a41 --- /dev/null +++ b/lms/static/sass/discussion/views/_thread.scss @@ -0,0 +1,126 @@ +// discussion - thread layout +// ==================== + +// general thread layout +body.discussion, .discussion-module { + + // post layout + .discussion-post { + padding: ($baseline*2) ($baseline*2) $baseline ($baseline*2); + border-radius: 3px 3px 0 0; + background-color: $white; + + .post-header-content { + display: inline-block; + width: flex-grid(9,12); + } + + .post-header-actions { + display: inline-block; + float: right; + vertical-align: middle; + width: flex-grid(3,12); + } + } + + // response layout + .discussion-response { + min-height: ($baseline*7.5); + + .username { + @include font-size(14); + @extend %t-weight5; + } + + .response-header-content { + display: inline-block; + vertical-align: top; + width: flex-grid(9,12); + } + + .response-header-actions { + width: flex-grid(3,12); + float: right; + } + } + + // comments layout + .comments { + @extend %ui-no-list; + border-radius: 0 0 3px 3px; + background: $gray-l6; + box-shadow: 0 1px 3px -1px $shadow inset; + + > li { + border-top: 1px solid $gray-l4; + padding: ($baseline/2) $baseline; + } + + + blockquote { + background: $gray-l4; + border-radius: 3px; + padding: ($baseline/4) ($baseline/2); + font-size: 14px; + } + + .comment-form { + @include clearfix; + + .comment-form-input { + padding: ($baseline/4) ($baseline/2); + background-color: $white; + font-size: 14px; + } + + .discussion-submit-comment { + @include blue-button; + float: left; + margin-top: 8px; + } + + .wmd-input { + height: 40px; + } + + .discussion-errors { + margin: 0; + } + } + + .response-body { + display: inline-block; + margin-bottom: ($baseline/2); + width: flex-grid(10,12); + font-size: 13px; + + p + p { + margin-top: 12px; + } + } + + .comment-actions-list { + display: inline-block; + width: flex-grid(2,12); + vertical-align: top; + } + + //TO-DO : clean up posted-details styling, currently reused by responses and comments + .posted-details { + margin-top: 0; + } + } +} + +.forum-thread-main-wrapper { + border-bottom: 1px solid $white; // Prevent collapsing margins + border-radius: 3px 3px 0 0; + background-color: $white; +} + +body.discussion, .discussion-thread.expanded { + .forum-thread-main-wrapper { + box-shadow: 0 1px 3px $shadow; + } +} + diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss index 776c2e3ac2..f094f78df1 100644 --- a/lms/static/sass/elements/_controls.scss +++ b/lms/static/sass/elements/_controls.scss @@ -237,6 +237,54 @@ } } +// blue secondary button outline style +%btn-secondary-blue-outline { + @extend %t-action2; + @extend %btn; + @extend %btn-edged; + box-shadow: none; + border: 1px solid $m-blue-d3; + padding: ($baseline/2) $baseline; + background: transparent; + color: $m-blue-d3; + + &:hover, &:active, &:focus { + box-shadow: 0 2px 1px 0 $m-blue-d4; + background: $m-blue-d1; + color: $white; + } + + &.current, &.active { + box-shadow: inset 0 2px 1px 1px $m-blue-d2; + background: $m-blue; + color: $m-blue-d2; + + &:hover, &:active, &:focus { + box-shadow: inset 0 2px 1px 1px $m-blue-d3; + color: $m-blue-d3; + } + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + +// grey secondary button outline style +%btn-secondary-grey-outline { + @extend %btn-secondary-blue-outline; + border: 1px solid $gray-l4; + + &:hover, &:active, &:focus { + box-shadow: none; + border: 1px solid $m-blue-d3; + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + // ==================== // application: canned actions diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 26cafdc432..00b19e636d 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -230,6 +230,21 @@ margin: 0 0 ($baseline/4) 0; } } + + .cta-login { + + h3.title, + .instructions { + display: inline-block; + margin-bottom: 0; + } + + .cta-login-action { + @extend %btn-secondary-grey-outline; + padding: ($baseline/10) ($baseline*.75); + margin-left: ($baseline/4); + } + } } // forms @@ -275,6 +290,17 @@ } } + .group-form-personalinformation { + + .field-education-level, + .field-gender, + .field-yob { + display: inline-block; + vertical-align: top; + margin-bottom: 0; + } + } + // individual fields .field { margin: 0 0 $baseline 0; @@ -304,6 +330,16 @@ font-size: em(13); } + &.password { + position: relative; + + .tip { + position: absolute; + top: 0; + right: 0; + } + } + input, textarea { width: 100%; margin: 0; @@ -432,9 +468,7 @@ } .action-primary { - float: left; width: flex-grid(8,8); - margin-right: flex-gutter(0); } .action-secondary { @@ -452,16 +486,71 @@ } // forms - third-party auth - .form-third-party-auth { + + // UI: deco - divider + .deco-divider { + position: relative; + display: block; + margin: ($baseline*1.5) 0; + border-top: ($baseline/5) solid $m-gray-l4; + + .copy { + @extend %t-copy-lead1; + @extend %t-weight4; + position: absolute; + top: -($baseline); + left: 43%; + padding: ($baseline/4) ($baseline*1.5); + background: white; + text-align: center; + color: $m-gray-l2; + } + } + + // downplay required note + .instructions .note { + @extend %t-copy-sub2; + display: block; + font-weight: normal; + color: $gray; + } + + .form-actions.form-third-party-auth { + width: flex-grid(8,8); margin-bottom: $baseline; - button { - margin-right: $baseline; + button[type="submit"] { + @extend %btn-secondary-blue-outline; + width: flex-grid(4,8); + margin-right: ($baseline/2); .icon { color: inherit; margin-right: $baseline/2; } + + &:last-child { + margin-right: 0; + } + + &.button-Google:hover { + box-shadow: 0 2px 1px 0 #8D3024; + background-color: #dd4b39; + border: 1px solid #A5382B; + } + + &.button-Facebook:hover { + box-shadow: 0 2px 1px 0 #30487C; + background-color: #3b5998; + border: 1px solid #263A62; + } + + &.button-LinkedIn:hover { + box-shadow: 0 2px 1px 0 #005D8E; + background-color: #0077b5; + border: 1px solid #06527D; + } + } } @@ -536,7 +625,6 @@ .introduction { header { height: 120px; - border-bottom: 1px solid $m-gray; background: transparent $login-banner-image 0 0 no-repeat; } } @@ -548,7 +636,6 @@ .introduction { header { height: 120px; - border-bottom: 1px solid $m-gray; background: transparent $register-banner-image 0 0 no-repeat; } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6f5f4c1e71..a2a2f1a90b 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -110,17 +110,40 @@ .third-party-auth { color: inherit; font-weight: inherit; + } - .control { - float: right; - } + .auth-provider { + width: flex-grid(12); + display: block; + margin-top: ($baseline/4); - .icon { - margin-top: 4px; + .status { + width: flex-grid(1); + display: inline-block; + color: $gray-l2; + + .icon-link { + color: $base-font-color; + } + + .copy { + @extend %text-sr; + } } .provider { - display: inline; + width: flex-grid(9); + display: inline-block; + } + + .control { + width: flex-grid(2); + display: inline-block; + text-align: right; + + a:link, a:visited { + @extend %t-copy-sub2; + } } } } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 4f086d901c..8099775f95 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -198,7 +198,7 @@ % if duplicate_provider:
    ## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. - ${_('The selected {provider_name} account is already linked to another {platform_name} account. Please {link_start}log out{link_end}, then log in with your {provider_name} account.').format(link_end='', link_start='' % logout_url, provider_name='%s' % duplicate_provider.NAME, platform_name=platform_name)} +

    ${_('The {provider_name} account you selected is already linked to another {platform_name} account.').format(provider_name='%s' % duplicate_provider.NAME, platform_name=platform_name)}

    % endif @@ -226,22 +226,23 @@ % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
  3. ${entry} @@ -42,11 +42,6 @@
  4. ${_("All Discussions")}
  5. - %if flag_moderator: -
  6. - ${_("Flagged Discussions")} -
  7. - %endif
  8. ${_("Posts I'm Following")}
  9. diff --git a/lms/templates/discussion/_paginator.html b/lms/templates/discussion/_paginator.html deleted file mode 100644 index bb94b64289..0000000000 --- a/lms/templates/discussion/_paginator.html +++ /dev/null @@ -1,62 +0,0 @@ -<%! from urllib import urlencode %> - -<% - def merge(dic1, dic2): - return dict(dic1.items() + dic2.items()) - - def url_for_page(_page): - return base_url + '?' + urlencode(merge(query_params, {'page': _page})) -%> - -<%def name="link_to_page(_page, text)"> - ${text} - - -<%def name="div_page(_page)"> - % if _page != page: - - % else: - - % endif - - -<%def name="list_pages(*args)"> - % for arg in args: - % if arg == 'dots': -
    ...
    - % elif isinstance(arg, list): - % for _page in arg: - ${div_page(_page)} - % endfor - % else: - ${div_page(arg)} - % endif - % endfor - - -
    -
    - % if page > 1: - ${link_to_page(page - 1, "< Previous page")} - % endif -
    - - % if num_pages <= 2 * pages_nearby_delta + 2: - ${list_pages(range(1, num_pages + 1))} - % else: - % if page <= 2 * pages_nearby_delta: - ${list_pages(range(1, 2 * pages_nearby_delta + 2), 'dots', num_pages)} - % elif num_pages - page + 1 <= 2 * pages_nearby_delta: - ${list_pages(1, 'dots', range(num_pages - 2 * pages_nearby_delta, num_pages + 1))} - % else: - ${list_pages(1, 'dots', range(page - pages_nearby_delta, page + pages_nearby_delta + 1), 'dots', num_pages)} - % endif - % endif -
    - % if page < num_pages: - ${link_to_page(page + 1, "Next page >")} - % endif -
    -
    diff --git a/lms/templates/discussion/_sort.html b/lms/templates/discussion/_sort.html deleted file mode 100644 index 99ba58f7c8..0000000000 --- a/lms/templates/discussion/_sort.html +++ /dev/null @@ -1,41 +0,0 @@ -<%! from urllib import urlencode %> - -<%def name="link_to_sort(key, title)"> - % if key == sort_key: - ${_link_to_sort(key, None, title + '', 'sorted')} - - % else: - ${_link_to_sort(key, 'desc', title)} - % endif - - -<%def name="_link_to_sort(key, order, title, cls='')"> - <% - def merge(dic1, dic2): - return dict(dic1.items() + dic2.items()) - - def url_for_sort(key, order): - if order is None: - return '' - else: - return base_url + '?' + urlencode(merge(query_params, {'page': 1, 'sort_key': key, 'sort_order': order})) - %> - ${title} - - -
    - Sort by: - ${link_to_sort('date', 'date')} - - ${link_to_sort('activity', 'top')} - - ${link_to_sort('votes', 'votes')} - - ${link_to_sort('comments', 'comments')} -
    diff --git a/lms/templates/discussion/_thread_list_template.html b/lms/templates/discussion/_thread_list_template.html index 2dac6167e8..66ef69120f 100644 --- a/lms/templates/discussion/_thread_list_template.html +++ b/lms/templates/discussion/_thread_list_template.html @@ -20,18 +20,41 @@ <%include file="_filter_dropdown.html" />
    + \ %if is_course_cohorted and is_moderator: - +## Lack of indentation is intentional to avoid whitespace between this and siblings + + \ %endif - - +## Lack of indentation is intentional to avoid whitespace between this and siblings + +
      diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index d6dc58ffca..14727e18f5 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -1,32 +1,46 @@ <%! 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. @@ -37,40 +51,43 @@
      ${"<%- obj.group_string%>"}
      ${"<% } %>"} - -

      ${'<%- title %>'}

      -

      - ${"<% if (obj.username) { %>"} - ${'<%- username %>'} - ${"<% } else { %>"} - ${_('anonymous') | h} - ${"<% } %>"} - ${'<%- created_at %>'} +

      - -

      - - ${_("Follow this post")} - +

      ${'<%- title %>'}

      +

      + ## This part is incredibly gross but necessary to combine i18n in + ## mako with logic in underscore. + ## Translators: post_type describes the kind of post this is + ## (e.g. "question" or "discussion"); time_ago is how much time + ## has passed since the post was created (e.g. "4 hours ago") + ${_("{post_type} posted {time_ago} by {author}").format( + post_type="<%- thread_type %>", + time_ago="<%- created_at %>", + author="<%= author_display %>" + )} +

      + +
      +
      + ${"""<%= + _.template( + $('#forum-actions').html(), + { + contentId: cid, + contentType: 'post', + primaryActions: ['vote', 'follow'], + secondaryActions: ['pin', 'edit', 'delete', 'report', 'close'] + } + ) + %>"""} +
      ${'<%- body %>'}
      -
      - ${_("Report Misuse")}
      - - - % if course and has_permission(user, 'openclose_thread', course.id): -
      - ${_("Pin Thread")}
      - - %else: - ${"<% if (pinned) { %>"} -
      - ${_("Pinned")}
      - ${"<% } %>"} - % endif <% js_block = u""" var courseware_link = interpolate('%s', [courseware_url, _.escape(courseware_title)]); @@ -80,15 +97,9 @@ escapejs(_("(this post is about %(courseware_title_linked)s)")) ) %> - ${'<% if (obj.courseware_url) { %>'} + ${'<% if (mode == "tab" && obj.courseware_url) { %>'}
      ${'<%'}${js_block}${'%>'}
      ${'<% } %>'} - -
      @@ -110,8 +121,12 @@ @@ -207,22 +259,45 @@ - - - - - - + +<%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"))} + + - - - diff --git a/lms/templates/discussion/index.html b/lms/templates/discussion/index.html index 1ed39aeede..38eddd692d 100644 --- a/lms/templates/discussion/index.html +++ b/lms/templates/discussion/index.html @@ -23,8 +23,6 @@ <%include file="_discussion_course_navigation.html" args="active_page='discussion'" /> -
      -
      + +
      diff --git a/lms/templates/discussion/mustache/_inline_thread.mustache b/lms/templates/discussion/mustache/_inline_thread.mustache deleted file mode 100644 index eda47700e2..0000000000 --- a/lms/templates/discussion/mustache/_inline_thread.mustache +++ /dev/null @@ -1,34 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - - diff --git a/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache b/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache deleted file mode 100644 index 4c6fac4cd9..0000000000 --- a/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache +++ /dev/null @@ -1,33 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - - diff --git a/lms/templates/discussion/mustache/_inline_thread_show.mustache b/lms/templates/discussion/mustache/_inline_thread_show.mustache deleted file mode 100644 index c1f05bb9f9..0000000000 --- a/lms/templates/discussion/mustache/_inline_thread_show.mustache +++ /dev/null @@ -1,36 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -
      -
      -
      - -

      {{title}}

      -
      -
      - -
      - ${_("Pinned")}
      - -

      - {{#user}} - {{username}} - {{/user}} - {{^user}} - ${_("anonymous")} - {{/user}} - - {{created_at}} - - -

      -
      -
      {{abbreviatedBody}}
      - - -
      diff --git a/lms/templates/discussion/mustache/_pagination.mustache b/lms/templates/discussion/mustache/_pagination.mustache index f984e8d38d..11389e3862 100644 --- a/lms/templates/discussion/mustache/_pagination.mustache +++ b/lms/templates/discussion/mustache/_pagination.mustache @@ -1,6 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> -