From 58c5066e66881f4b769e9a5b61ac1aed757e0568 Mon Sep 17 00:00:00 2001 From: jsa Date: Tue, 3 Jun 2014 11:06:34 -0400 Subject: [PATCH] Add support for search spell corrections to Forums UX. Co-authored-by: Brian Talbot JIRA: FOR-591 --- common/djangoapps/terrain/stubs/comments.py | 4 + .../discussion_thread_list_view_spec.coffee | 100 ++++ .../views/discussion_thread_list_view.coffee | 32 ++ common/test/acceptance/fixtures/discussion.py | 71 ++- .../test/acceptance/pages/lms/discussion.py | 54 +- .../test/acceptance/tests/test_discussion.py | 57 ++- .../internal_data_formats/tracking_logs.rst | 32 +- .../django_comment_client/forum/views.py | 4 +- lms/lib/comment_client/thread.py | 7 +- lms/static/sass/_discussion-old.scss | 480 ------------------ lms/static/sass/application-extend2.scss.mako | 3 +- .../discussion/_discussion-developer.scss | 78 +++ .../sass/{ => discussion}/_discussion.scss | 0 .../discussion/_thread_list_template.html | 1 + .../discussion/_underscore_templates.html | 24 +- 15 files changed, 416 insertions(+), 531 deletions(-) create mode 100644 common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee delete mode 100644 lms/static/sass/_discussion-old.scss create mode 100644 lms/static/sass/discussion/_discussion-developer.scss rename lms/static/sass/{ => discussion}/_discussion.scss (100%) diff --git a/common/djangoapps/terrain/stubs/comments.py b/common/djangoapps/terrain/stubs/comments.py index 80d209abf4..c454177df3 100644 --- a/common/djangoapps/terrain/stubs/comments.py +++ b/common/djangoapps/terrain/stubs/comments.py @@ -17,6 +17,7 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): pattern_handlers = { "/api/v1/users/(?P\\d+)/active_threads$": self.do_user_profile, "/api/v1/users/(?P\\d+)$": self.do_user, + "/api/v1/search/threads$": self.do_search_threads, "/api/v1/threads$": self.do_threads, "/api/v1/threads/(?P\\w+)$": self.do_thread, "/api/v1/comments/(?P\\w+)$": self.do_comment, @@ -86,6 +87,9 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): def do_threads(self): self.send_json_response({"collection": [], "page": 1, "num_pages": 1}) + def do_search_threads(self): + self.send_json_response(self.server.config.get('search_result', {})) + def do_comment(self, comment_id): # django_comment_client calls GET comment before doing a DELETE, so that's what this is here to support. if comment_id in self.server.config.get('comments', {}): 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 new file mode 100644 index 0000000000..260fc07492 --- /dev/null +++ b/common/static/coffee/spec/discussion/view/discussion_thread_list_view_spec.coffee @@ -0,0 +1,100 @@ +describe "DiscussionThreadListView", -> + + beforeEach -> + + setFixtures """ + + + + """ + window.$$course_id = "TestOrg/TestCourse/TestRun" + window.user = new DiscussionUser({id: "567", upvoted_ids: []}) + + spyOn($, "ajax") + + @discussion = new Discussion([]) + @view = new DiscussionThreadListView({collection: @discussion, el: $(".sidebar")}) + @view.render() + + testAlertMessages = (expectedMessages) -> + expect($(".search-alert .message").map( -> + $(@).html() + ).get()).toEqual(expectedMessages) + + it "renders and removes search alerts", -> + testAlertMessages [] + foo = @view.addSearchAlert("foo") + testAlertMessages ["foo"] + bar = @view.addSearchAlert("bar") + testAlertMessages ["foo", "bar"] + @view.removeSearchAlert(foo.cid) + testAlertMessages ["bar"] + @view.removeSearchAlert(bar.cid) + testAlertMessages [] + + it "clears all search alerts", -> + @view.addSearchAlert("foo") + @view.addSearchAlert("bar") + @view.addSearchAlert("baz") + testAlertMessages ["foo", "bar", "baz"] + @view.clearSearchAlerts() + testAlertMessages [] + + testCorrection = (view, correctedText) -> + spyOn(view, "addSearchAlert") + $.ajax.andCallFake( + (params) => + params.success( + {discussion_data: [], page: 42, num_pages: 99, corrected_text: correctedText}, 'success' + ) + {always: ->} + ) + view.searchFor("dummy") + expect($.ajax).toHaveBeenCalled() + + it "adds a search alert when an alternate term was searched", -> + testCorrection(@view, "foo") + expect(@view.addSearchAlert).toHaveBeenCalled() + expect(@view.addSearchAlert.mostRecentCall.args[0]).toMatch(/foo/) + + it "does not add a search alert when no alternate term was searched", -> + testCorrection(@view, null) + expect(@view.addSearchAlert).not.toHaveBeenCalled() + + it "clears search alerts when a new search is performed", -> + spyOn(@view, "clearSearchAlerts") + spyOn(DiscussionUtil, "safeAjax") + @view.searchFor("dummy") + expect(@view.clearSearchAlerts).toHaveBeenCalled() + + it "clears search alerts when the underlying collection changes", -> + spyOn(@view, "clearSearchAlerts") + spyOn(@view, "renderThread") + @view.collection.trigger("change", new Thread({id: 1})) + expect(@view.clearSearchAlerts).toHaveBeenCalled() 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 7fa48a9bdd..d9ab6515dc 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 @@ -36,7 +36,36 @@ if Backbone? @current_search = "" @mode = 'all' + @searchAlertCollection = new Backbone.Collection([], {model: Backbone.Model}) + + @searchAlertCollection.on "add", (searchAlert) => + content = _.template( + $("#search-alert-template").html(), + {'message': searchAlert.attributes.message, 'cid': searchAlert.cid} + ) + @$(".search-alerts").append(content) + @$("#search-alert-" + searchAlert.cid + " a.dismiss").bind "click", searchAlert, (event) => + @removeSearchAlert(event.data.cid) + + @searchAlertCollection.on "remove", (searchAlert) => + @$("#search-alert-" + searchAlert.cid).remove() + + @searchAlertCollection.on "reset", => + @$(".search-alerts").empty() + + addSearchAlert: (message) => + m = new Backbone.Model({"message": message}) + @searchAlertCollection.add(m) + m + + removeSearchAlert: (searchAlert) => + @searchAlertCollection.remove(searchAlert) + + clearSearchAlerts: => + @searchAlertCollection.reset() + reloadDisplayedCollection: (thread) => + @clearSearchAlerts() thread_id = thread.get('id') content = @renderThread(thread) current_el = @$("a[data-id=#{thread_id}]") @@ -405,6 +434,7 @@ if Backbone? @searchFor(text) searchFor: (text, callback, value) -> + @clearSearchAlerts() @mode = 'search' @current_search = text url = DiscussionUtil.urlFor("search") @@ -429,6 +459,8 @@ if Backbone? Content.loadContentInfos(response.annotated_content_info) @collection.current_page = response.page @collection.pages = response.num_pages + if !_.isNull response.corrected_text + @addSearchAlert('Showing results for "' + response.corrected_text + '"'); # TODO: Perhaps reload user info so that votes can be updated. # In the future we might not load all of a user's votes at once # so this would probably be necessary anyway diff --git a/common/test/acceptance/fixtures/discussion.py b/common/test/acceptance/fixtures/discussion.py index 4d3061a15c..38da67acd6 100644 --- a/common/test/acceptance/fixtures/discussion.py +++ b/common/test/acceptance/fixtures/discussion.py @@ -39,7 +39,6 @@ class Thread(ContentFactory): pinned = False read = False - class Comment(ContentFactory): thread_id = None depth = 0 @@ -52,7 +51,34 @@ class Response(Comment): body = "dummy response body" -class SingleThreadViewFixture(object): +class SearchResult(factory.Factory): + FACTORY_FOR = dict + discussion_data = [] + annotated_content_info = {} + num_pages = 1 + page = 1 + corrected_text = None + + +class DiscussionContentFixture(object): + + def push(self): + """ + Push the data to the stub comments service. + """ + requests.put( + '{}/set_config'.format(COMMENTS_STUB_URL), + data=self.get_config_data() + ) + + def get_config_data(self): + """ + return a dictionary with the fixture's data serialized for PUTting to the stub server's config endpoint. + """ + raise NotImplementedError() + + +class SingleThreadViewFixture(DiscussionContentFixture): def __init__(self, thread): self.thread = thread @@ -76,30 +102,27 @@ class SingleThreadViewFixture(object): return res return dict(_visit(self.thread)) - def push(self): - """ - Push the data to the stub comments service. - """ - requests.put( - '{}/set_config'.format(COMMENTS_STUB_URL), - data={ - "threads": json.dumps({self.thread['id']: self.thread}), - "comments": json.dumps(self._get_comment_map()) - } - ) + def get_config_data(self): + return { + "threads": json.dumps({self.thread['id']: self.thread}), + "comments": json.dumps(self._get_comment_map()) + } -class UserProfileViewFixture(object): + +class UserProfileViewFixture(DiscussionContentFixture): def __init__(self, threads): self.threads = threads - def push(self): - """ - Push the data to the stub comments service. - """ - requests.put( - '{}/set_config'.format(COMMENTS_STUB_URL), - data={ - "active_threads": json.dumps(self.threads), - } - ) + def get_config_data(self): + return {"active_threads": json.dumps(self.threads)} + + +class SearchResultFixture(DiscussionContentFixture): + + def __init__(self, result): + self.result = result + + def get_config_data(self): + return {"search_result": json.dumps(self.result)} + diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index 2ec2894131..bccc16a183 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -4,7 +4,13 @@ from bok_choy.promise import EmptyPromise from .course_page import CoursePage -class DiscussionThreadPage(PageObject): +class DiscussionPageMixin(object): + + def is_ajax_finished(self): + return self.browser.execute_script("return jQuery.active") == 0 + + +class DiscussionThreadPage(PageObject, DiscussionPageMixin): url = None def __init__(self, browser, thread_selector): @@ -53,11 +59,8 @@ class DiscussionThreadPage(PageObject): """Clicks the load more responses button and waits for responses to load""" self._find_within(".load-response-button").click() - def _is_ajax_finished(): - return self.browser.execute_script("return jQuery.active") == 0 - EmptyPromise( - _is_ajax_finished, + self.is_ajax_finished, "Loading more Responses" ).fulfill() @@ -304,3 +307,44 @@ class DiscussionUserProfilePage(CoursePage): def click_on_page(self, page_number): self._click_pager_with_text(unicode(page_number), page_number) + +class DiscussionTabHomePage(CoursePage, DiscussionPageMixin): + + ALERT_SELECTOR = ".discussion-body .sidebar .search-alert" + + def __init__(self, browser, course_id): + super(DiscussionTabHomePage, self).__init__(browser, course_id) + self.url_path = "discussion/forum/" + + def is_browser_on_page(self): + return self.q(css=".discussion-body section.home-header").present + + def perform_search(self): + self.q(css=".discussion-body .sidebar .search").first.click() + EmptyPromise( + lambda: self.q(css=".discussion-body .sidebar .search.is-open").present, + "waiting for search input to be available" + ).fulfill() + self.q(css="#search-discussions").fill("dummy" + chr(10)) + EmptyPromise( + self.is_ajax_finished, + "waiting for server to return result" + ).fulfill() + + def get_search_alert_messages(self): + return self.q(css=self.ALERT_SELECTOR + " .message").text + + def dismiss_alert_message(self, text): + """ + dismiss any search alert message containing the specified text. + """ + def _match_messages(text): + return self.q(css=".search-alert").filter(lambda elem: text in elem.text) + + for alert_id in _match_messages(text).attrs("id"): + self.q(css="{}#{} a.dismiss".format(self.ALERT_SELECTOR, alert_id)).click() + EmptyPromise( + lambda: _match_messages(text).results == [], + "waiting for dismissed alerts to disappear" + ).fulfill() + diff --git a/common/test/acceptance/tests/test_discussion.py b/common/test/acceptance/tests/test_discussion.py index 2ca4defa5f..f7dc37fcbb 100644 --- a/common/test/acceptance/tests/test_discussion.py +++ b/common/test/acceptance/tests/test_discussion.py @@ -11,10 +11,19 @@ from ..pages.lms.discussion import ( DiscussionTabSingleThreadPage, InlineDiscussionPage, InlineDiscussionThreadPage, - DiscussionUserProfilePage + DiscussionUserProfilePage, + DiscussionTabHomePage ) from ..fixtures.course import CourseFixture, XBlockFixtureDesc -from ..fixtures.discussion import SingleThreadViewFixture, UserProfileViewFixture, Thread, Response, Comment +from ..fixtures.discussion import ( + SingleThreadViewFixture, + UserProfileViewFixture, + SearchResultFixture, + Thread, + Response, + Comment, + SearchResult +) class DiscussionResponsePaginationTestMixin(object): @@ -405,3 +414,47 @@ class DiscussionUserProfileTest(UniqueCourseTest): def test_151_threads(self): self.check_pages(151) + +class DiscussionSearchAlertTest(UniqueCourseTest): + """ + Tests for spawning and dismissing alerts related to user search actions and their results. + """ + + def setUp(self): + super(DiscussionSearchAlertTest, self).setUp() + CourseFixture(**self.course_info).install() + AutoAuthPage(self.browser, course_id=self.course_id).visit() + self.page = DiscussionTabHomePage(self.browser, self.course_id) + self.page.visit() + + def setup_corrected_text(self, text): + SearchResultFixture(SearchResult(corrected_text=text)).push() + + def check_search_alert_messages(self, expected): + actual = self.page.get_search_alert_messages() + self.assertTrue(all(map(lambda msg, sub: msg.find(sub) >= 0, actual, expected))) + + def test_no_rewrite(self): + self.setup_corrected_text(None) + self.page.perform_search() + self.check_search_alert_messages([]) + + def test_rewrite_dismiss(self): + self.setup_corrected_text("foo") + self.page.perform_search() + self.check_search_alert_messages(["foo"]) + self.page.dismiss_alert_message("foo") + self.check_search_alert_messages([]) + + def test_new_search(self): + self.setup_corrected_text("foo") + self.page.perform_search() + self.check_search_alert_messages(["foo"]) + + self.setup_corrected_text("bar") + self.page.perform_search() + self.check_search_alert_messages(["bar"]) + + self.setup_corrected_text(None) + self.page.perform_search() + self.check_search_alert_messages([]) diff --git a/docs/en_us/data/source/internal_data_formats/tracking_logs.rst b/docs/en_us/data/source/internal_data_formats/tracking_logs.rst index 221f3d2821..f622ce81fc 100644 --- a/docs/en_us/data/source/internal_data_formats/tracking_logs.rst +++ b/docs/en_us/data/source/internal_data_formats/tracking_logs.rst @@ -1933,19 +1933,31 @@ After a user executes a text search in the navigation sidebar of the Discussion **Event Source**: Server -**History**: Added 16 May 2014. +**History**: Added 16 May 2014. The ``corrected_text`` field was added on June 5 2014. ``event`` **Fields**: -+---------------------+---------------+----------------------------------------------------------------------------------------------------+ -| Field | Type | Details | -+=====================+===============+====================================================================================================+ -| ``query`` | string | The text entered into the search box by the user. | -+---------------------+---------------+----------------------------------------------------------------------------------------------------+ -| ``page`` | integer | Results are returned in sets of 20 per page. Identifies the page of results requested by the user. | -+---------------------+---------------+----------------------------------------------------------------------------------------------------+ -| ``total_results`` | integer | The total number of results matching the query. | -+---------------------+---------------+----------------------------------------------------------------------------------------------------+ +.. list-table:: + :widths: 15 15 60 + :header-rows: 1 + + * - Field + - Type + - Details + * - ``query`` + - string + - The text entered into the search box by the user. + * - ``page`` + - integer + - Results are returned in sets of 20 per page. Identifies the page of results requested by the user. + * - ``total_results`` + - integer + - The total number of results matching the query. + * - ``corrected_text`` + - string + - A re-spelling of the query, suggested by the search engine, which was automatically substituted for the original + one. This happens only when there are no results for the original query, but the index contains matches for + a similar term or phrase. Otherwise, this field is null. .. _Instructor_Event_Types: diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index fa89fa35c2..d9cf8dc7d9 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -85,7 +85,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG 'sort_order', 'text', 'commentable_ids', 'flagged']))) - threads, page, num_pages = cc.Thread.search(query_params) + threads, page, num_pages, corrected_text = cc.Thread.search(query_params) #now add the group name if the thread has a group id for thread in threads: @@ -103,6 +103,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG query_params['page'] = page query_params['num_pages'] = num_pages + query_params['corrected_text'] = corrected_text return threads, query_params @@ -198,6 +199,7 @@ def forum_form_discussion(request, course_id): 'annotated_content_info': annotated_content_info, 'num_pages': query_params['num_pages'], 'page': query_params['page'], + 'corrected_text': query_params['corrected_text'], }) else: with newrelic.agent.FunctionTrace(nr_transaction, "get_cohort_info"): diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index 55c6cc4707..0e8d0c19b4 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -63,25 +63,28 @@ class Thread(models.Model): course_id = query_params['course_id'] requested_page = params['page'] total_results = response.get('total_results') + corrected_text = response.get('corrected_text') # Record search result metric to allow search quality analysis. # course_id is already included in the context for the event tracker tracker.emit( 'edx.forum.searched', { 'query': search_query, + 'corrected_text': corrected_text, 'page': requested_page, 'total_results': total_results, } ) log.info( - 'forum_text_search query="{search_query}" course_id={course_id} page={requested_page} total_results={total_results}'.format( + 'forum_text_search query="{search_query}" corrected_text="{corrected_text}" course_id={course_id} page={requested_page} total_results={total_results}'.format( search_query=search_query, + corrected_text=corrected_text, course_id=course_id, requested_page=requested_page, total_results=total_results ) ) - return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1), response.get('corrected_text') @classmethod def url_for_threads(cls, params={}): diff --git a/lms/static/sass/_discussion-old.scss b/lms/static/sass/_discussion-old.scss deleted file mode 100644 index 8d1152cbcc..0000000000 --- a/lms/static/sass/_discussion-old.scss +++ /dev/null @@ -1,480 +0,0 @@ -@mixin blue-button { - display: block; - height: 33px; - margin: 12px; - padding: 0 15px; - border-radius: 3px; - border: 1px solid #4697c1; - background: -webkit-linear-gradient(top, #6dccf1, #38a8e5); - font-size: 13px; - font-weight: 700; - line-height: 30px; - color: #fff; - text-shadow: 0 1px 0 rgba(0, 0, 0, .4); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - - &:hover { - border-color: #297095; - background: -webkit-linear-gradient(top, #4fbbe4, #2090d0); - } -} - -.discussion-body { - - .vote-btn { - float: right; - display: block; - height: 27px; - padding: 0 8px; - border-radius: 5px; - border: 1px solid #b2b2b2; - background: -webkit-linear-gradient(top, #fff 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 { - float: left; - margin-right: 6px; - font-size: 18px; - color: #17b429; - } - - &.is-cast { - border-color: #379a42; - background: -webkit-linear-gradient(top, #50cc5e, #3db84b); - color: #fff; - text-shadow: 0 1px 0 rgba(0, 0, 0, .3); - box-shadow: 0 1px 0 rgba(255, 255, 255, .4) inset, 0 1px 2px rgba(0, 0, 0, .2); - - .plus-icon { - color: #336a39; - text-shadow: 0 1px 0 rgba(255, 255, 255, .4); - } - } - } - - - .new-post-btn { - @include blue-button; - float: right; - } - - .new-post-icon { - display: block; - float: left; - width: 16px; - height: 17px; - margin: 7px 7px 0 0; - font-size: 16px; - padding-right: $baseline/2; - vertical-align: middle; - color: $white; - } - - .post-search { - float: right; - } - - .post-search-field { - width: 280px; - height: 30px; - padding: 0 15px 0 30px; - margin-top: 14px; - border: 1px solid #acacac; - border-radius: 30px; - box-shadow: 0 1px 3px rgba(0, 0, 0, .1) inset, 0 1px 0 rgba(255, 255, 255, .5); - background: url(../images/search-icon.png) no-repeat 8px center #fff; - font-family: 'Open Sans', sans-serif; - font-weight: 400; - font-size: 13px; - line-height: 30px; - color: #333; - outline: 0; - -webkit-transition: border-color .1s; - - &:focus { - border-color: #4697c1; - } - } - - h1, ul, li, a, ol { - margin: 0; - padding: 0; - border: 0; - outline: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; - } - ul, li { - list-style-type: none; - } - a { - text-decoration: none; - color: #009fe2; - } - - display: table; - table-layout: fixed; - width: 100%; - height: 500px; - background: #fff; - border-radius: 3px; - border: 1px solid #aaa; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - - .sidebar { - display: table-cell; - vertical-align: top; - width: 27.7%; - background: #f6f6f6; - border-radius: 3px 0 0 3px; - border-right: 1px solid #bcbcbc; - .post-list { - background-color: #ddd; - - li:last-child a { - border-bottom: 1px solid #ddd; - } - - a { - position: relative; - display: block; - height: 36px; - padding: 0 10px; - margin-bottom: 1px; - background: #fff; - font-size: 13px; - font-weight: 700; - line-height: 34px; - color: #333; - - &.read .title { - font-weight: 400; - color: #737373; - } - - &.followed:after { - content: ''; - position: absolute; - top: 0; - right: 0; - width: 12px; - height: 12px; - background: url(../images/following-flag.png) no-repeat; - } - - &.active { - background: -webkit-linear-gradient(top, #96e0fd, #61c7fc); - border-color: #4697c1; - box-shadow: 0 1px 0 #4697c1, 0 -1px 0 #4697c1; - - .title { - color: #333; - } - - .votes-count, - .comments-count { - background: -webkit-linear-gradient(top, #3994c7, #4da7d3); - color: #fff; - - &:after { - color: #4da7d3; - } - } - - &.followed:after { - background-position: 0 -12px; - } - } - } - - .title { - display: block; - float: left; - width: 70%; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .votes-count, - .comments-count { - display: block; - float: right; - width: 32px; - height: 16px; - margin-top: 9px; - border-radius: 2px; - background: -webkit-linear-gradient(top, #d4d4d4, #dfdfdf); - font-size: 9px; - font-weight: 700; - line-height: 16px; - text-align: center; - color: #767676; - } - - .comments-count { - position: relative; - margin-left: 4px; - - &:after { - content: '◥'; - display: block; - position: absolute; - top: 11px; - right: 3px; - font-size: 6px; - color: #dfdfdf; - } - - &.new { - background: -webkit-linear-gradient(top, #84d7fe, #99e0fe); - color: #333; - - &:after { - color: #99e0fe; - } - } - } - } - } - - .board-drop-btn { - display: block; - height: 60px; - border-bottom: 1px solid #a3a3a3; - border-radius: 3px 0 0 0; - background: -webkit-linear-gradient(top, #ebebeb, #d9d9d9); - font-size: 16px; - font-weight: 700; - line-height: 58px; - text-align: center; - color: #333; - text-shadow: 0 1px 0 rgba(255, 255, 255, .8); - } - - .sort-bar { - height: 27px; - border-bottom: 1px solid #a3a3a3; - background: -webkit-linear-gradient(top, #cdcdcd, #b6b6b6); - box-shadow: 0 1px 0 rgba(255, 255, 255, .4) inset; - - a { - display: block; - float: right; - height: 27px; - margin-right: 10px; - font-size: 11px; - font-weight: bold; - line-height: 23px; - color: #333; - text-shadow: 0 1px 0 rgba(255, 255, 255, .5); - - .sort-label { - font-size: 9px; - text-transform: uppercase; - } - } - } -} - - - - - - - - - - - - -.global-discussion-actions { - height: 60px; - background: -webkit-linear-gradient(top, #ebebeb, #d9d9d9); - border-radius: 0 3px 0 0; - border-bottom: 1px solid #bcbcbc; -} - - - - - - - - - - - -.discussion-article { - position: relative; - display: table-cell; - vertical-align: top; - width: 72.3%; - padding: 40px; - - h1 { - font-size: 28px; - font-weight: 700; - } - - .posted-details { - font-size: 12px; - font-style: italic; - color: #888; - } - - p + p { - margin-top: 20px; - } - - .dogear { - display: block; - position: absolute; - top: 0; - right: -1px; - width: 52px; - height: 51px; - background: url(../images/follow-dog-ear.png) 0 -51px no-repeat; - - &.is-followed { - background-position: 0 0; - } - } -} - -.discussion-post header, -.responses li header { - margin-bottom: 20px; -} - -.responses { - margin-top: 40px; - - > li { - margin: 0 -10px; - padding: 30px; - border-radius: 3px; - border: 1px solid #b2b2b2; - box-shadow: 0 1px 3px rgba(0, 0, 0, .15); - } - - .posted-by { - font-weight: 700; - } -} - -.endorse-btn { - display: block; - float: right; - width: 27px; - height: 27px; - margin-right: 10px; - border-radius: 27px; - border: 1px solid #a0a0a0; - background: -webkit-linear-gradient(top, #fff 35%, #ebebeb); - box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - - .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; - background: -webkit-linear-gradient(top, #6dccf1, #38a8e5); - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(255, 255, 255, .4) inset; - - .check-icon { - background-position: 0 -12px; - } - } -} - -.comments { - margin-top: 20px; - border-top: 1px solid #ddd; - - li { - background: #f6f6f6; - border-bottom: 1px solid #ddd; - } - - p { - font-size: 13px; - padding: 10px 20px; - - .posted-details { - font-size: 11px; - white-space: nowrap; - } - } -} - -.comment-form { - padding: 8px 20px; -} - -.comment-form-input { - width: 100%; - height: 31px; - padding: 0 10px; - box-sizing: border-box; - border: 1px solid #b2b2b2; - border-radius: 3px; - box-shadow: 0 1px 3px rgba(0, 0, 0, .1) inset; - -webkit-transition: border-color .1s; - outline: 0; - - &:focus { - border-color: #4697c1; - } -} - -.moderator-actions { - margin-top: 20px; - @include clearfix; - - li { - float: left; - margin-right: 8px; - } - - a { - display: block; - height: 26px; - padding: 0 12px; - border-radius: 3px; - border: 1px solid #b2b2b2; - background: -webkit-linear-gradient(top, #fff 35%, #ebebeb); - font-size: 13px; - line-height: 24px; - color: #737373; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - - .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; - } - } -} diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index cfb6a1b698..7c78a2993e 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -48,7 +48,8 @@ @import 'views/shoppingcart'; // applications -@import 'discussion'; +@import 'discussion/discussion'; +@import 'discussion/discussion-developer'; @import 'news'; // temp - shame and developer diff --git a/lms/static/sass/discussion/_discussion-developer.scss b/lms/static/sass/discussion/_discussion-developer.scss new file mode 100644 index 0000000000..6cae0aab31 --- /dev/null +++ b/lms/static/sass/discussion/_discussion-developer.scss @@ -0,0 +1,78 @@ +// discussion: - developer +// ==================== + +// NOTES: +// * use this area for any developer-needed or created styling that needs to be refactored into patterns or visually +// polished. Please list any template/view that reference your styles when definining them (example below): + +// -------------------- +// Views: Error +// -------------------- +// .crazy-new-feature { +// background: transparent; +// } + +// -------------------- +// Views: forum_form_discussion / single_thread +// provisional styling for "search alerts" (messages boxes that appear in the sidebar below the search +// input field with notices pertaining to the search result). +// -------------------- +body.discussion { + + .sidebar { + + // wrapper for multiple alerts + .search-alerts { + + } + + // a single alert, which can be independently displayed / dismissed + .search-alert { + @include transition(none); + padding: ($baseline/2) 11px ($baseline/2) 18px; + background-color: $black; + } + + .search-alert-content, .search-alert-controls { + display: inline-block; + vertical-align: middle; + } + + // alert content + .search-alert-content { + width: 70%; + + // alert copy + .message { + @include font-size(12); + @extend %t-weight5; + color: $white; + } + + // links to jump to users/content in alerts + .link-jump { + @include transition(none); + @extend %t-weight5; + } + } + + // alert controls + .search-alert-controls { + width: 28%; + text-align: right; + + .control { + @include font-size(14); + @include transition(none); + @extend %t-weight5; + padding: ($baseline/4) ($baseline/2); + + // reseting poorly globally scoped hover/focus state for this control + &:hover, &:focus { + color: $link-color; + text-decoration: underline; + } + } + } + } +} diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/discussion/_discussion.scss similarity index 100% rename from lms/static/sass/_discussion.scss rename to lms/static/sass/discussion/_discussion.scss diff --git a/lms/templates/discussion/_thread_list_template.html b/lms/templates/discussion/_thread_list_template.html index 58a6f25201..c91fc9187f 100644 --- a/lms/templates/discussion/_thread_list_template.html +++ b/lms/templates/discussion/_thread_list_template.html @@ -45,6 +45,7 @@ %endif +
diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index aa06f4e32b..fbe0b4c422 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -170,14 +170,14 @@ ${_("Delete Comment")}
${_("Edit")}
- <% + <% js_block = u""" interpolate( '{}', {{'time_ago': '' + created_at + ''}}, true )""".format( - ## Translators: 'timeago' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago) + ## Translators: 'timeago' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago) escapejs(_('-posted %(time_ago)s by')) ) %> @@ -207,9 +207,9 @@ + +