Commit 58c5066e by jsa

Add support for search spell corrections to Forums UX.

Co-authored-by: Brian Talbot <btalbot@edx.org>

JIRA: FOR-591
parent 2eae8b83
......@@ -17,6 +17,7 @@ class StubCommentsServiceHandler(StubHttpRequestHandler):
pattern_handlers = {
"/api/v1/users/(?P<user_id>\\d+)/active_threads$": self.do_user_profile,
"/api/v1/users/(?P<user_id>\\d+)$": self.do_user,
"/api/v1/search/threads$": self.do_search_threads,
"/api/v1/threads$": self.do_threads,
"/api/v1/threads/(?P<thread_id>\\w+)$": self.do_thread,
"/api/v1/comments/(?P<comment_id>\\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', {}):
......
describe "DiscussionThreadListView", ->
beforeEach ->
setFixtures """
<script type="text/template" id="thread-list-template">
<div class="browse-search">
<div class="home"></div>
<div class="browse is-open"></div>
<div class="search">
<form class="post-search">
<label class="sr" for="search-discussions">Search</label>
<input type="text" id="search-discussions" placeholder="Search all discussions" class="post-search-field">
</form>
</div>
</div>
<div class="sort-bar"></div>
<div class="search-alerts"></div>
<div class="post-list-wrapper">
<ul class="post-list"></ul>
</div>
</script>
<script aria-hidden="true" type="text/template" id="search-alert-template">
<div class="search-alert" id="search-alert-<%- cid %>">
<div class="search-alert-content">
<p class="message"><%- message %></p>
</div>
<div class="search-alert-controls">
<a href="#" class="dismiss control control-dismiss"><i class="icon icon-remove"></i></a>
</div>
</div>
</script>
<div class="sidebar"></div>
"""
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()
......@@ -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
......
......@@ -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)}
......@@ -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()
......@@ -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([])
......@@ -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:
......
......@@ -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"):
......
......@@ -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={}):
......
@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;
}
}
}
......@@ -48,7 +48,8 @@
@import 'views/shoppingcart';
// applications
@import 'discussion';
@import 'discussion/discussion';
@import 'discussion/discussion-developer';
@import 'news';
// temp - shame and developer
......
// 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;
}
}
}
}
}
......@@ -45,6 +45,7 @@
</select>
%endif
</div>
<div class="search-alerts"></div>
<div class="post-list-wrapper">
<ul class="post-list">
</ul>
......
......@@ -170,14 +170,14 @@
<i class="icon icon-remove"></i><span class="sr">${_("Delete Comment")}</span></div>
<div class="discussion-edit-comment action-edit" data-tooltip="${_('Edit') | h}" role="button" tabindex="0">
<i class="icon icon-pencil"></i><span class="sr">${_("Edit")}</span></div>
<%
<%
js_block = u"""
interpolate(
'{}',
{{'time_ago': '<span class=\"timeago\" title=\"' + created_at + '\">' + created_at + '</span>'}},
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 @@
<script aria-hidden="true" type="text/template" id="thread-list-item-template">
<a href="${'<%- id %>'}" data-id="${'<%- id %>'}">
<span class="title">${"<%- title %>"}</span>
<%
<%
js_block = u"""
var fmt;
var fmt;
var data = {{
'span_sr_open': '<span class=\"sr\">',
'span_close': '</span>',
......@@ -232,7 +232,7 @@
<span class="comments-count">
${'<%'}${js_block}${'%>'}
</span>
<%
<%
js_block = u"""
interpolate(
'{}',
......@@ -255,7 +255,7 @@
<h1 class="home-title">${course.display_name_with_default}</h1>
% endif
</section>
% if settings.FEATURES.get('ENABLE_DISCUSSION_HOME_PANEL'):
<span class="label label-settings">${_("HOW TO USE EDX DISCUSSIONS")}</span>
<table class="home-helpgrid">
......@@ -307,3 +307,15 @@
</div>
</script>
<script aria-hidden="true" type="text/template" id="search-alert-template">
<div class="search-alert" id="search-alert-${'<%- cid %>'}">
<div class="search-alert-content">
<p class="message">${'<%- message %>'}</p>
</div>
<div class="search-alert-controls">
<a href="#" class="dismiss control control-dismiss"><i class="icon icon-remove"></i></a>
</div>
</div>
</script>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment